Public: anyone can read this; the crew can change it.
The terminal lists your last bulk actions, and `u` undoes the selected one while it is inside its day #7945
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7937-the-terminal-lists-your-last-bulk-action → main 5 commits fork
7 files
changed +415
−3
docs/CREW.md +2
| @@ -345,6 +345,8 @@ one the provider rests hands its runs to the other until the reset. | ||
| 345 | 345 | |
| 346 | 346 | **Invites.** A forge admin who is a person mints a single-use invite link with `POST /api/v1/admin/invites` (`{"days": 14}`, 1 to 30, 7 by default); the response carries the link once and only its hash is kept. `GET /api/v1/admin/invites` lists them (open, used, revoked, expired) without tokens, and `DELETE /api/v1/admin/invites/:id` revokes one. Minting, revoking and using a link are events (`invite.minted`, `invite.revoked`, `invite.used`; the last names the invite and the account it let in, never the token). The same is the **Invite links** card on `/admin/limits` (a form with expiry days, 1 to 30; the new link is shown once, with a copy button; an open link has a Revoke button) and the terminal's **Invite links** command (`m` makes one and prints the link once, `x` revokes the selected open one; its top line says who may sign up, and `s`, offered only to that admin, picks open, invite or closed, shows from → to and changes it after a confirm, as MCP `signup` does). Over MCP, the `mint_invite` tool (`{days?}`, an admin token, a forge admin who is a person) answers with the same link, and the `invites` tool (`{action: "list"|"revoke", id?}`, the same person admin and admin token, for both actions) lists them without links or revokes one. While signup is `invite`, the link opens the door once: `/signup?invite=…` (the form and the passkey door) or `invite` in the `POST /api/v1/users` body. In the terminal, with signup = invite the create-account screen has a fourth, masked **Invite** field that takes the token (not the whole link); a blank one is asked for, and a bad one is refused with the same sentence as `/signup`. The account and the invite's use are one write, so two sign-ups with one link make one account; a used, revoked or expired link, or any link while signup is `closed`, makes none. While signup is `open`, a link is not spent. |
| 347 | 347 | |
| 348 | **Bulk actions in the terminal.** A person (never an agent) presses Ctrl+K and picks **Bulk actions** (`src/tui/screens/bulk.ts`): their own last 30 bulk actions, newest first, as `GET /api/v1/fleet/ops` lists them, each with its action, how many rows changed, how many were refused, whether `u` still undoes it, and when. `j`/`k` move; `u` undoes the selected one while it is inside its day, through `undoFleetOp` (`undoAsksBulk` for a bulk action on asks), and toasts the outcome; an undone, expired or non-reversible receipt, or an undo itself, says why and changes nothing. Enter opens the operation's rows: each row's before → after, and each refusal's reason. Another person's operations are never listed. An agent is not offered the screen and, reached another way, sees "Not available". | |
| 349 | ||
| 348 | 350 | **Spend ceilings.** Apart from the grant, a forge admin who is a person may set any account's — a person's, an agent's or an organization's — daily (UTC) and per-run ceilings in dollars: on the account's page (`/settings/accounts/<name>`, an organization's too, by that address; an agent and an admin's own account have no such page), the terminal's **Model ceiling** command, `POST /api/v1/admin/accounts/:name/ceiling` (`{"dailyUsd": 20, "perRunUsd": 5}`) or the MCP `model_ceiling` tool; each change is in the audit. Both empty on the page, `none` in the terminal (its prompt shows the current ceilings, and an empty answer changes nothing), or `null` for both on the API and MCP takes them back. An organization's page is linked from its own page for an admin. An account has no ceiling until one is set, so this forge's own crew is never stopped by a default nobody chose. A run on an account with a ceiling does not start when the day's is spent or the per-run one is too small, is capped at the smaller of the per-run ceiling and what is left of the day, and stops mid-run when the day's is spent. |
| 349 | 351 | **Sandbox.** An agent's shell is held to a policy — the network is the forge |
| 350 | 352 | and the package registry, writes stay in the clone, the forge's data and the |
docs/FLEET.md +3 −1
| @@ -74,7 +74,9 @@ one row per selected target: the value before, the value asked for, and | ||
| 74 | 74 | `ok`, `failed`, `refused` or `unchanged` with the reason. One |
| 75 | 75 | `fleet.operated` event — for its actor alone, since it names repositories |
| 76 | 76 | across the forge — carries the counts. `GET /api/v1/fleet/ops` lists your |
| 77 | operations; `GET /api/v1/fleet/ops/:id` is one receipt. | |
| 77 | operations; `GET /api/v1/fleet/ops/:id` is one receipt. In the terminal, | |
| 78 | Ctrl+K → **Bulk actions** lists the same receipts and `u` undoes one | |
| 79 | inside its day (`docs/CREW.md` says how). | |
| 78 | 80 | |
| 79 | 81 | Undo is a new operation that writes each `ok` row's value from before, |
| 80 | 82 | only where the row still holds what the operation left; a row someone |
src/tui/screens/bulk.ts added +171
| @@ -0,0 +1,171 @@ | ||
| 1 | /** | |
| 2 | * Your bulk actions in the terminal (#7937): the operations you took, newest | |
| 3 | * first, as `listFleetOps` has them — action, rows changed, rows refused, | |
| 4 | * when. `u` undoes the selected one while it is inside its day, through | |
| 5 | * `undoFleetOp` (`undoAsksBulk` for one on asks); an undone, expired or | |
| 6 | * undo receipt says why and changes nothing. `enter` opens its rows: each | |
| 7 | * one's before → after and each refusal's reason. The screen is a person's: | |
| 8 | * an agent may preview a mass change, never apply or undo one. | |
| 9 | */ | |
| 10 | ||
| 11 | import { getFleetOp, listFleetOps, undoFleetOp, UNDO_WINDOW_MS, isAsksOperation } from "../../actions/fleet.ts"; | |
| 12 | import { undoable, undoAsksBulk } from "../../actions/asks_bulk.ts"; | |
| 13 | import type { Operation } from "../../core/fleet_ops.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { truncate } from "../term/width.ts"; | |
| 19 | import { fill, len, split } from "../ui/layout.ts"; | |
| 20 | import { emptyState } from "../ui/widgets.ts"; | |
| 21 | import { ListState, ago, drawList, plural, runAsync, type ListRow } from "./common.ts"; | |
| 22 | import type { PaletteEntry } from "./entries.ts"; | |
| 23 | ||
| 24 | const LIMIT = 30; | |
| 25 | ||
| 26 | const clean = (s: string): string => s.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, "").trim(); | |
| 27 | const show = (v: unknown): string => clean(v === null || v === undefined ? "—" : Array.isArray(v) ? v.join(", ") : String(v)); | |
| 28 | ||
| 29 | /** Why `u` does nothing on a receipt, in words; empty when it undoes. */ | |
| 30 | export function whyNot(op: Operation, now = Date.now()): string { | |
| 31 | if (undoable(op, now)) return ""; | |
| 32 | if (op.undoOf !== null) return "this is an undo, and is not undone again"; | |
| 33 | if (op.undoneBy !== null) return "this operation was undone already"; | |
| 34 | if (!op.reversible) return `${clean(op.verb)} cannot be undone`; | |
| 35 | if (now - Date.parse(op.createdAt) > UNDO_WINDOW_MS) | |
| 36 | return "an undo is kept for a day, and this one's day is over"; | |
| 37 | return "this operation cannot be undone"; | |
| 38 | } | |
| 39 | ||
| 40 | /** Whether a session is a person's: a bulk action is theirs to take and undo, never an agent's. */ | |
| 41 | export const isPerson = (session: Session): boolean => | |
| 42 | session.user !== undefined && session.user.kind === "user"; | |
| 43 | ||
| 44 | /** How long the list and its counts are kept before they are read again. */ | |
| 45 | const KEEP_MS = 2000; | |
| 46 | ||
| 47 | function stateOf(op: Operation): string { | |
| 48 | if (op.undoOf !== null) return "undo"; | |
| 49 | if (op.undoneBy !== null) return "undone"; | |
| 50 | return undoable(op) ? "u undoes" : "final"; | |
| 51 | } | |
| 52 | ||
| 53 | /** One operation's rows: label · before → after, or the reason a row was refused. */ | |
| 54 | function rowsScreen(f: Session, ctx: Ctx, op: Operation): Screen { | |
| 55 | const state = new ListState(); | |
| 56 | const full = getFleetOp(f.app, f.user, op.id); | |
| 57 | const rows = full.ok ? full.value.rows : []; | |
| 58 | return { | |
| 59 | title: "Bulk action rows", | |
| 60 | keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")], | |
| 61 | render(area, s) { | |
| 62 | const c = ctx.theme.c; | |
| 63 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 64 | s.text(head!.x, head!.y, truncate(`${clean(op.verb)} · ${clean(op.query)}`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 65 | if (rows.length === 0) { | |
| 66 | emptyState(s, body!, c, { title: "No rows kept." }); | |
| 67 | return; | |
| 68 | } | |
| 69 | const list: ListRow[] = rows.map((r) => ({ | |
| 70 | title: clean(r.label), | |
| 71 | secondary: r.outcome === "ok" ? `${show(r.before)} → ${show(r.after)}` : `${r.outcome}: ${clean(r.reason)}`, | |
| 72 | tone: r.outcome === "ok" ? "success" : "danger", | |
| 73 | })); | |
| 74 | drawList(s, body!, c, state, list); | |
| 75 | }, | |
| 76 | key(e) { | |
| 77 | if (e.ctrl || e.alt) return false; | |
| 78 | if (e.key === "esc" || e.key === "q") { | |
| 79 | ctx.pop(); | |
| 80 | return true; | |
| 81 | } | |
| 82 | return state.key(e, rows.length); | |
| 83 | }, | |
| 84 | }; | |
| 85 | } | |
| 86 | ||
| 87 | export function bulkScreen(f: Session, ctx: Ctx): Screen { | |
| 88 | const state = new ListState(); | |
| 89 | const person = (): boolean => isPerson(f); | |
| 90 | // The list and its counts are read once and kept, not on every frame and key. | |
| 91 | let cache: { at: number; items: { op: Operation; done: number; refused: number }[] } | undefined; | |
| 92 | const items = (): { op: Operation; done: number; refused: number }[] => { | |
| 93 | if (!person()) return []; | |
| 94 | if (cache !== undefined && Date.now() - cache.at < KEEP_MS) return cache.items; | |
| 95 | const r = listFleetOps(f.app, f.user, LIMIT); | |
| 96 | const read = (r.ok ? r.value : []).map((op) => { | |
| 97 | const full = getFleetOp(f.app, f.user, op.id); | |
| 98 | const rows = full.ok ? full.value.rows : []; | |
| 99 | const done = rows.filter((x) => x.outcome === "ok").length; | |
| 100 | return { op, done, refused: rows.length - done }; | |
| 101 | }); | |
| 102 | cache = { at: Date.now(), items: read }; | |
| 103 | return read; | |
| 104 | }; | |
| 105 | ||
| 106 | const undo = (op: Operation): void => { | |
| 107 | const why = whyNot(op); | |
| 108 | if (why !== "") { | |
| 109 | ctx.toast(why, "attn"); | |
| 110 | return; | |
| 111 | } | |
| 112 | runAsync(ctx, async () => { | |
| 113 | const done = isAsksOperation(op) | |
| 114 | ? await undoAsksBulk(f.app, f.user, op.id) | |
| 115 | : await undoFleetOp(f.app, f.user, op.id); | |
| 116 | cache = undefined; | |
| 117 | ctx.toast(done.ok ? "undone" : done.error.message, done.ok ? "success" : "danger"); | |
| 118 | ctx.invalidate(); | |
| 119 | }); | |
| 120 | }; | |
| 121 | ||
| 122 | const screen: Screen = { | |
| 123 | title: "Bulk actions", | |
| 124 | keys: [ | |
| 125 | binding(["j", "k"], "move"), | |
| 126 | binding(["u"], "undo the selected one, inside its day"), | |
| 127 | binding(["enter"], "its rows"), | |
| 128 | ], | |
| 129 | render(area, s) { | |
| 130 | const c = ctx.theme.c; | |
| 131 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 132 | s.text(head!.x, head!.y, truncate("your bulk actions, newest first", head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 133 | const all = items(); | |
| 134 | if (all.length === 0) { | |
| 135 | emptyState(s, body!, c, { | |
| 136 | title: person() ? "No bulk actions yet" : "Not available", | |
| 137 | body: person() ? "Archive or dismiss several at once and it is listed here." : "A bulk action is a person's call.", | |
| 138 | }); | |
| 139 | return; | |
| 140 | } | |
| 141 | const rows: ListRow[] = all.map(({ op, done, refused }) => ({ | |
| 142 | title: `${clean(op.verb)} · ${clean(op.query)}`, | |
| 143 | secondary: `${plural(done, "row")} changed, ${refused} refused`, | |
| 144 | meta: `${stateOf(op)} · ${ago(op.createdAt)}`, | |
| 145 | })); | |
| 146 | drawList(s, body!, c, state, rows); | |
| 147 | }, | |
| 148 | key(e) { | |
| 149 | const all = items(); | |
| 150 | if (state.key(e, all.length)) return true; | |
| 151 | if (e.ctrl || e.alt || !person()) return false; | |
| 152 | const op = all[state.index]?.op; | |
| 153 | if (op === undefined) return false; | |
| 154 | if (e.key === "u") undo(op); | |
| 155 | else if (e.key === "enter") ctx.push(rowsScreen(f, ctx, op)); | |
| 156 | else return false; | |
| 157 | return true; | |
| 158 | }, | |
| 159 | }; | |
| 160 | return screen; | |
| 161 | } | |
| 162 | ||
| 163 | /** Your bulk actions: a person's, never an agent's. */ | |
| 164 | export const entry: PaletteEntry = { | |
| 165 | id: "bulk", | |
| 166 | title: "Bulk actions", | |
| 167 | hint: "your last bulk actions; u undoes one inside its day", | |
| 168 | after: ["invites", "teams", "limits"], | |
| 169 | offered: isPerson, | |
| 170 | run: (session, ctx) => ctx.push(bulkScreen(session, ctx)), | |
| 171 | }; |
src/tui/screens/entries.ts +2
| @@ -12,6 +12,7 @@ import { entry as access } from "./access.ts"; | ||
| 12 | 12 | import { entry as agents } from "./agents.ts"; |
| 13 | 13 | import { entry as audit } from "./audit.ts"; |
| 14 | 14 | import { entry as boards } from "./boards.ts"; |
| 15 | import { entry as bulk } from "./bulk.ts"; | |
| 15 | 16 | import { entry as cloud } from "./cloud.ts"; |
| 16 | 17 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 17 | 18 | import { entry as crewSplits } from "./crewsplits.ts"; |
| @@ -69,6 +70,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 69 | 70 | teams, |
| 70 | 71 | agents, |
| 71 | 72 | invites, |
| 73 | bulk, | |
| 72 | 74 | subscriptions, |
| 73 | 75 | cloud, |
| 74 | 76 | domains, |
test/palette-roster.test.ts +2
| @@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 100 | 100 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 101 | 101 | ["agents", "Crew agents", "each agent's model, budget and brief"], |
| 102 | 102 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 103 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], | |
| 103 | 104 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 104 | 105 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 105 | 106 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| @@ -191,6 +192,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 191 | 192 | ["model", "Model budget", "what your crew may spend, and add your own subscription"], |
| 192 | 193 | ["can", "What I can do", "your role and what it allows here"], |
| 193 | 194 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 195 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], | |
| 194 | 196 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 195 | 197 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 196 | 198 | ["chain", "Chain", "the forges this forge's code descends from, root first"], |
test/tui-bulk.test.ts added +230
| @@ -0,0 +1,230 @@ | ||
| 1 | /** The terminal lists your bulk actions, and `u` undoes the selected one inside its day (#7937). */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { applyAsksBulk } from "../src/actions/asks_bulk.ts"; | |
| 8 | import { applyFleetIds, undoFleetOp, UNDO_WINDOW_MS } from "../src/actions/fleet.ts"; | |
| 9 | import { openIssue } from "../src/actions/issues.ts"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import type { Operation } from "../src/core/fleet_ops.ts"; | |
| 13 | import { getIssueById, setAssignee } from "../src/core/issues.ts"; | |
| 14 | import { getRepo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { bulkScreen, entry, whyNot } from "../src/tui/screens/bulk.ts"; | |
| 18 | import { Session } from "../src/tui/session.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | describe("bulk actions in the terminal", () => { | |
| 26 | let dataDir: string; | |
| 27 | let forge: Forge; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | ||
| 31 | function boot(user: User) { | |
| 32 | const terminal = headlessTerminal(140, 32); | |
| 33 | const { app } = createForgeApp( | |
| 34 | forge, | |
| 35 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 36 | { terminal, exitHooks: false }, | |
| 37 | ); | |
| 38 | app.start(); | |
| 39 | const key = (k: string, ctrl = false) => | |
| 40 | app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 41 | return { | |
| 42 | app, | |
| 43 | text: () => app.screen.toText(), | |
| 44 | press: async (...keys: string[]) => { | |
| 45 | keys.forEach((k) => key(k)); | |
| 46 | await settleApp(app); | |
| 47 | }, | |
| 48 | push: async (user2: User) => { | |
| 49 | app.push(bulkScreen(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: user2 }), app.ctx)); | |
| 50 | await settleApp(app); | |
| 51 | }, | |
| 52 | open: async () => { | |
| 53 | key("k", true); | |
| 54 | await settleApp(app); | |
| 55 | for (const ch of "Bulk actions") key(ch); | |
| 56 | key("enter"); | |
| 57 | await settleApp(app); | |
| 58 | }, | |
| 59 | }; | |
| 60 | } | |
| 61 | const archived = (name: string): boolean => getRepo(forge.db, "alice", name)?.archivedAt != null; | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-bulk-")); | |
| 65 | forge = createApp({ dataDir, port: 0 }); | |
| 66 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 67 | const made = await createUser(forge.db, { | |
| 68 | origin: "seed", | |
| 69 | trusted: true, | |
| 70 | username, | |
| 71 | email: `${username}@forge.local`, | |
| 72 | password: "a-good-passphrase", | |
| 73 | adminIfFirst, | |
| 74 | }); | |
| 75 | if (!made.ok) throw new Error("user"); | |
| 76 | } | |
| 77 | alice = getUserByName(forge.db, "alice")!; | |
| 78 | bob = getUserByName(forge.db, "bob")!; | |
| 79 | const made = await createRepo(forge, alice, { name: "solo", visibility: "public" }); | |
| 80 | if (!made.ok) throw new Error("repo"); | |
| 81 | }); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | forge.close(); | |
| 85 | await rm(dataDir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an archive, then u wakes the repository; a second u is refused; bob never sees it", async () => { | |
| 89 | const op = await applyFleetIds(forge, alice, "repo", [getRepo(forge.db, "alice", "solo")!.id], "archive alice/solo", { verb: "archive", value: true }); | |
| 90 | if (!op.ok) throw new Error(op.error.message); | |
| 91 | expect(archived("solo")).toBe(true); | |
| 92 | ||
| 93 | const other = boot(bob); | |
| 94 | try { | |
| 95 | await other.open(); | |
| 96 | expect(other.text()).toContain("No bulk actions yet"); | |
| 97 | expect(other.text()).not.toContain("archive"); | |
| 98 | } finally { | |
| 99 | other.app.stop(); | |
| 100 | } | |
| 101 | ||
| 102 | const h = boot(alice); | |
| 103 | try { | |
| 104 | await h.open(); | |
| 105 | expect(h.text()).toContain("archive"); | |
| 106 | expect(h.text()).toContain("1 row changed, 0 refused"); | |
| 107 | expect(h.text()).toContain("u undoes"); | |
| 108 | await h.press("u"); | |
| 109 | await settleUntil(h.app, "undone"); | |
| 110 | expect(archived("solo")).toBe(false); | |
| 111 | // The undo is a receipt of its own and heads the list; the original is below it. | |
| 112 | await h.press("j", "u"); | |
| 113 | expect(h.text()).toContain("undone already"); | |
| 114 | expect(archived("solo")).toBe(false); | |
| 115 | // Enter opens the rows: the receipt's before and after. | |
| 116 | await h.press("enter"); | |
| 117 | expect(h.text()).toContain("alice/solo"); | |
| 118 | expect(h.text()).toContain("→"); | |
| 119 | } finally { | |
| 120 | h.app.stop(); | |
| 121 | } | |
| 122 | }); | |
| 123 | ||
| 124 | const archiveOne = async (name: string) => { | |
| 125 | const made = await createRepo(forge, alice, { name, visibility: "public" }); | |
| 126 | if (!made.ok) throw new Error("repo"); | |
| 127 | const op = await applyFleetIds(forge, alice, "repo", [made.value.id], `archive alice/${name}`, { verb: "archive", value: true }); | |
| 128 | if (!op.ok) throw new Error(op.error.message); | |
| 129 | return op.value; | |
| 130 | }; | |
| 131 | ||
| 132 | test("whyNot says why each kind of receipt is not undone", () => { | |
| 133 | const base: Operation = { | |
| 134 | id: "x", actorId: "a", actorName: "alice", query: "q", kind: "repo", verb: "archive", | |
| 135 | value: true, reversible: true, undoOf: null, undoneBy: null, createdAt: new Date().toISOString(), rows: [], | |
| 136 | }; | |
| 137 | expect(whyNot(base)).toBe(""); | |
| 138 | expect(whyNot({ ...base, undoneBy: "y" })).toContain("undone already"); | |
| 139 | expect(whyNot({ ...base, undoOf: "y" })).toContain("is an undo"); | |
| 140 | expect(whyNot({ ...base, reversible: false })).toContain("cannot be undone"); | |
| 141 | const old = new Date(Date.now() - UNDO_WINDOW_MS - 60_000).toISOString(); | |
| 142 | expect(whyNot({ ...base, createdAt: old })).toContain("day is over"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("an expired receipt is refused on u and nothing changes", async () => { | |
| 146 | const op = await archiveOne("old"); | |
| 147 | const old = new Date(Date.now() - UNDO_WINDOW_MS - 60_000).toISOString(); | |
| 148 | forge.db.query("UPDATE fleet_operation SET created_at = ?1 WHERE id = ?2").run(old, op.id); | |
| 149 | const h = boot(alice); | |
| 150 | try { | |
| 151 | await h.open(); | |
| 152 | await h.press("u"); | |
| 153 | expect(h.text()).toContain("day is over"); | |
| 154 | expect(getRepo(forge.db, "alice", "old")?.archivedAt).not.toBeNull(); | |
| 155 | } finally { | |
| 156 | h.app.stop(); | |
| 157 | } | |
| 158 | }); | |
| 159 | ||
| 160 | test("u on an undo receipt does nothing", async () => { | |
| 161 | const op = await archiveOne("again"); | |
| 162 | const undone = await undoFleetOp(forge, alice, op.id); | |
| 163 | if (!undone.ok) throw new Error(undone.error.message); | |
| 164 | const h = boot(alice); | |
| 165 | try { | |
| 166 | await h.open(); | |
| 167 | await h.press("u"); | |
| 168 | expect(h.text()).toContain("is an undo"); | |
| 169 | } finally { | |
| 170 | h.app.stop(); | |
| 171 | } | |
| 172 | }); | |
| 173 | ||
| 174 | test("u undoes a bulk action on asks", async () => { | |
| 175 | const made = await createRepo(forge, alice, { name: "asky", visibility: "public" }); | |
| 176 | if (!made.ok) throw new Error("repo"); | |
| 177 | const issue = openIssue(forge, alice, made.value, { title: "Close me", body: "" }); | |
| 178 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 179 | setAssignee(forge.db, issue.value.id, alice.id); | |
| 180 | const done = await applyAsksBulk(forge, alice, "close", [`assigned:${issue.value.id}`], "done"); | |
| 181 | if (!done.ok) throw new Error(done.error.message); | |
| 182 | expect(getIssueById(forge.db, issue.value.id)!.state).toBe("closed"); | |
| 183 | const h = boot(alice); | |
| 184 | try { | |
| 185 | await h.open(); | |
| 186 | expect(h.text()).toContain("close"); | |
| 187 | await h.press("u"); | |
| 188 | await settleUntil(h.app, "undone"); | |
| 189 | expect(getIssueById(forge.db, issue.value.id)!.state).toBe("open"); | |
| 190 | } finally { | |
| 191 | h.app.stop(); | |
| 192 | } | |
| 193 | }); | |
| 194 | ||
| 195 | test("a failed undo shows the action's own words and changes nothing", async () => { | |
| 196 | const op = await archiveOne("raced"); | |
| 197 | const h = boot(alice); | |
| 198 | try { | |
| 199 | await h.open(); | |
| 200 | // Listed as undoable, then undone elsewhere before u is pressed: the list is kept for a moment. | |
| 201 | const first = await undoFleetOp(forge, alice, op.id); | |
| 202 | if (!first.ok) throw new Error(first.error.message); | |
| 203 | await h.press("u"); | |
| 204 | await settleUntil(h.app, "undone already"); | |
| 205 | expect(archived("raced")).toBe(false); | |
| 206 | } finally { | |
| 207 | h.app.stop(); | |
| 208 | } | |
| 209 | }); | |
| 210 | ||
| 211 | test("another person cannot undo it, and an agent is not offered the screen", async () => { | |
| 212 | const op = await archiveOne("mine"); | |
| 213 | const refused = await undoFleetOp(forge, bob, op.id); | |
| 214 | expect(refused.ok).toBe(false); | |
| 215 | expect(archived("mine")).toBe(true); | |
| 216 | ||
| 217 | const agent = { ...alice, kind: "agent" as const }; | |
| 218 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: agent }); | |
| 219 | expect(entry.offered(session)).toBe(false); | |
| 220 | expect(entry.offered(new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice }))).toBe(true); | |
| 221 | const h = boot(alice); | |
| 222 | try { | |
| 223 | await h.push(agent); | |
| 224 | expect(h.text()).toContain("Not available"); | |
| 225 | expect(h.text()).not.toContain("archive"); | |
| 226 | } finally { | |
| 227 | h.app.stop(); | |
| 228 | } | |
| 229 | }); | |
| 230 | }); |
test/tui-make-it-yours.test.ts +5 −2
| @@ -14,7 +14,7 @@ import { local } from "../src/tui/screens/where.ts"; | ||
| 14 | 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 15 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | 16 | import { promote } from "./helpers/members.ts"; |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 18 | 18 | import { proveAll } from "./helpers/verified.ts"; |
| 19 | 19 | |
| 20 | 20 | quietLogs(); |
| @@ -154,7 +154,10 @@ test("a private original is never copied public: refused in the action's words", | ||
| 154 | 154 | const h = await open(alice, made.value.repo); // a forge admin may read it |
| 155 | 155 | await h.press("Y"); |
| 156 | 156 | await h.press("tab"); |
| 157 | const text = await h.press("enter"); | |
| 157 | await h.press("enter"); | |
| 158 | // The refusal is the action's; under a loaded shard it may land a turn after the key settles. | |
| 159 | await settleUntil(h.app, "a copy of a private repository stays private"); | |
| 160 | const text = h.app.screen.toText(); | |
| 158 | 161 | expect(text).toContain("a copy of a private repository stays private"); |
| 159 | 162 | expect(getRepo(forge.db, "alice", "secret-plot")).toBeUndefined(); |
| 160 | 163 | h.app.stop(); |
new above
offline