Public: anyone can read this; the crew can change it.
Settling many asks at once says the same words and offers the same undo on the page, in the terminal and over MCP #10718
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10700-settling-many-asks-at-once-says-the-same → main 4 commits fork 2 threads · 2 unresolved
9 files
changed +261
−31
docs/FEED.md +3 −1
| @@ -161,7 +161,9 @@ one `POST /api/v1/asks/bulk` (`action`, `items`, `reason`, `preview: false`); | ||
| 161 | 161 | `items` are ask ids (`assigned:<id>` for something assigned to you; `issue:<id>` |
| 162 | 162 | still names one). The list swaps in place and the top bar's count follows. One undo link |
| 163 | 163 | (`POST /api/v1/asks/bulk/<id>/undo`) is kept for a day. The MCP's `asks_act` |
| 164 | takes the same set, verb and reason. | |
| 164 | takes the same set, verb and reason; it also undoes, with `action: undo` and | |
| 165 | `operation` (a dry run refuses an operation that is not yours with `not_found`), | |
| 166 | and returns the same receipt sentence. | |
| 165 | 167 | |
| 166 | 168 | ## The composer keeps your draft |
| 167 | 169 |
src/actions/asks_bulk.ts +9 −2
| @@ -12,7 +12,6 @@ import type { App } from "../app.ts"; | ||
| 12 | 12 | import { listOperations, type Operation } from "../core/fleet_ops.ts"; |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { NO_SUCH_OPERATION } from "./asks_words.ts"; | |
| 16 | 15 | import { askTargetsFor } from "../web/studio/asks.ts"; |
| 17 | 16 | import { |
| 18 | 17 | applyFleetIds, |
| @@ -24,7 +23,7 @@ import { | ||
| 24 | 23 | type FleetError, |
| 25 | 24 | type Plan, |
| 26 | 25 | } from "./fleet.ts"; |
| 27 | import { SIGN_IN_TO_ACT } from "./asks_words.ts"; | |
| 26 | import { NO_SUCH_OPERATION, receiptSentence, SIGN_IN_TO_ACT } from "./asks_words.ts"; | |
| 28 | 27 | |
| 29 | 28 | /** |
| 30 | 29 | * What a bulk action on asks may be: the fleet verb each one is. Decline is |
| @@ -195,3 +194,11 @@ export async function undoAsksBulk( | ||
| 195 | 194 | return err({ code: "not_found", message: NO_SUCH_OPERATION }); |
| 196 | 195 | return undoFleetOp(app, actor, op.id); |
| 197 | 196 | } |
| 197 | ||
| 198 | /** The one sentence that says what a bulk action on asks, or its undo, did. */ | |
| 199 | export const receiptOf = (op: Operation): string => | |
| 200 | receiptSentence( | |
| 201 | op.verb, | |
| 202 | op.rows.filter((r) => r.outcome === "ok").length, | |
| 203 | op.undoOf !== null, | |
| 204 | ); |
src/actions/asks_words.ts +23
| @@ -16,3 +16,26 @@ export const NO_SUCH_OPERATION = "no such operation here"; | ||
| 16 | 16 | |
| 17 | 17 | /** A milestone that is not here, or not readable by the one asking. */ |
| 18 | 18 | export const NO_SUCH_MILESTONE = "no such milestone here"; |
| 19 | ||
| 20 | /** Undoing a bulk operation that was undone before. */ | |
| 21 | export const ALREADY_UNDONE = "this operation was undone already"; | |
| 22 | ||
| 23 | /** The fleet verbs a bulk action on asks takes, as a person reads them. */ | |
| 24 | export const VERB_WORDS: Readonly<Record<string, string>> = { | |
| 25 | dismiss: "Dismiss", | |
| 26 | decline: "Decline", | |
| 27 | mute: "Mute repository", | |
| 28 | ship: "Ship", | |
| 29 | close: "Close", | |
| 30 | replan: "Hand back to the planner", | |
| 31 | }; | |
| 32 | ||
| 33 | /** | |
| 34 | * The receipt of a bulk action on asks in one sentence: "Dismiss: 3 done", | |
| 35 | * or "Dismiss undone: 3" for its undo. The page, the terminal and MCP say | |
| 36 | * exactly this. | |
| 37 | */ | |
| 38 | export function receiptSentence(verb: string, done: number, undo: boolean): string { | |
| 39 | const word = Object.hasOwn(VERB_WORDS, verb) ? VERB_WORDS[verb]! : verb; | |
| 40 | return undo ? `${word} undone: ${done}` : `${word}: ${done} done`; | |
| 41 | } |
src/actions/fleet.ts +2 −2
| @@ -58,7 +58,7 @@ import { | ||
| 58 | 58 | } from "../fleet/selection.ts"; |
| 59 | 59 | import { digestOf } from "../fleet/digest.ts"; |
| 60 | 60 | import { VERBS, type Target, type Verb } from "../fleet/verbs.ts"; |
| 61 | import { NO_SUCH_OPERATION } from "./asks_words.ts"; | |
| 61 | import { ALREADY_UNDONE, NO_SUCH_OPERATION } from "./asks_words.ts"; | |
| 62 | 62 | |
| 63 | 63 | export interface FleetError { |
| 64 | 64 | readonly code: |
| @@ -648,7 +648,7 @@ export async function undoFleetOp( | ||
| 648 | 648 | if (keepsADay(op) && Date.now() - Date.parse(op.createdAt) > UNDO_WINDOW_MS) |
| 649 | 649 | return fail("expired", "an undo is kept for a day, and this one's day is over"); |
| 650 | 650 | if (!claimUndo(app.db, op.id)) |
| 651 | return fail("already_undone", "this operation was undone already"); | |
| 651 | return fail("already_undone", ALREADY_UNDONE); | |
| 652 | 652 | let undone: Operation | undefined; |
| 653 | 653 | try { |
| 654 | 654 | const changed = op.rows.filter((r) => r.outcome === "ok"); |
src/http/mcp/tools/asks_act.ts +24 −9
| @@ -1,24 +1,26 @@ | ||
| 1 | import { applyAsksBulk, previewAsksBulk } from "../../../actions/asks_bulk.ts"; | |
| 1 | import { applyAsksBulk, ownAsksOperation, previewAsksBulk, receiptOf, undoAsksBulk } from "../../../actions/asks_bulk.ts"; | |
| 2 | import { NO_SUCH_OPERATION } from "../../../actions/asks_words.ts"; | |
| 2 | 3 | import { mutedRepos, removeMutes } from "../../../core/interactions.ts"; |
| 3 | 4 | import { good, bad, repoArg, type Tool } from "../shared.ts"; |
| 4 | 5 | |
| 5 | 6 | export const tool: Tool = { |
| 6 | 7 | name: "asks_act", |
| 7 | 8 | description: |
| 8 | "Decide asks (`items`): dismiss, decline, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes", | |
| 9 | "Decide asks (`items`): dismiss, ship, close (`reason`), mute, replan; `listen` (`repo`) unmutes; `undo` (`operation`)", | |
| 9 | 10 | inputSchema: { |
| 10 | 11 | type: "object", |
| 11 | 12 | properties: { |
| 12 | action: { type: "string", enum: ["dismiss", "decline", "mute", "listen", "ship", "close", "replan"] }, | |
| 13 | action: { type: "string", enum: ["dismiss", "decline", "mute", "listen", "undo", "ship", "close", "replan"] }, | |
| 13 | 14 | items: { type: "array", items: { type: "string" }, maxItems: 100 }, |
| 14 | 15 | repo: { type: "string" }, |
| 16 | operation: { type: "string" }, | |
| 15 | 17 | reason: { type: "string", maxLength: 300 }, |
| 16 | 18 | dryRun: { type: "boolean" }, |
| 17 | 19 | }, |
| 18 | 20 | required: ["action"], |
| 19 | 21 | }, |
| 20 | 22 | doc: |
| 21 | "`{ action, items?, repo?, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`, or `listen` with `repo` to unmute one; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk`", | |
| 23 | "`{ action, items?, repo?, reason?, dryRun? }`: decide asks in bulk (up to 100): `dismiss`, `decline`, `mute`, `ship`, `close` (with `reason`) or `replan`, `listen` with `repo` to unmute one, or `undo` with `operation` to reverse one of your own within a day; planned first (`dryRun` is true unless `false`), as `POST /api/v1/asks/bulk`", | |
| 22 | 24 | writes: true, |
| 23 | 25 | dryByDefault: true, |
| 24 | 26 | async run(ctx, user, args) { |
| @@ -33,10 +35,23 @@ export const tool: Tool = { | ||
| 33 | 35 | removeMutes(ctx.app.db, user.id, [`repo:${repo.id}`]); |
| 34 | 36 | return good({ listening: [name] }); |
| 35 | 37 | } |
| 36 | const answer = | |
| 37 | args["dryRun"] === false | |
| 38 | ? await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]) | |
| 39 | : previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]); | |
| 40 | return answer.ok ? good(answer.value) : bad(answer.error.code, answer.error.message); | |
| 38 | if (args["action"] === "undo") { | |
| 39 | if (typeof args["operation"] !== "string" || args["operation"] === "") | |
| 40 | return bad("invalid", "`undo` needs `operation`, the id of a bulk action"); | |
| 41 | if (args["dryRun"] !== false) { | |
| 42 | const own = ownAsksOperation(ctx.app, user, args["operation"]); | |
| 43 | return own === undefined ? bad("not_found", NO_SUCH_OPERATION) : good({ wouldUndo: own.id }); | |
| 44 | } | |
| 45 | const undone = await undoAsksBulk(ctx.app, user, args["operation"]); | |
| 46 | return undone.ok | |
| 47 | ? good({ ...undone.value, receipt: receiptOf(undone.value) }) | |
| 48 | : bad(undone.error.code, undone.error.message); | |
| 49 | } | |
| 50 | if (args["dryRun"] !== false) { | |
| 51 | const plan = previewAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]); | |
| 52 | return plan.ok ? good(plan.value) : bad(plan.error.code, plan.error.message); | |
| 53 | } | |
| 54 | const done = await applyAsksBulk(ctx.app, user, args["action"], args["items"], args["reason"]); | |
| 55 | return done.ok ? good({ ...done.value, receipt: receiptOf(done.value) }) : bad(done.error.code, done.error.message); | |
| 41 | 56 | }, |
| 42 | 57 | }; |
src/tui/screens/bulk.ts +6 −4
| @@ -9,7 +9,8 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { getFleetOp, listFleetOps, undoFleetOp, UNDO_WINDOW_MS, isAsksOperation } from "../../actions/fleet.ts"; |
| 12 | import { undoable, undoAsksBulk } from "../../actions/asks_bulk.ts"; | |
| 12 | import { ALREADY_UNDONE } from "../../actions/asks_words.ts"; | |
| 13 | import { receiptOf, undoable, undoAsksBulk } from "../../actions/asks_bulk.ts"; | |
| 13 | 14 | import type { Operation } from "../../core/fleet_ops.ts"; |
| 14 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -30,7 +31,7 @@ const show = (v: unknown): string => clean(v === null || v === undefined ? "—" | ||
| 30 | 31 | export function whyNot(op: Operation, now = Date.now()): string { |
| 31 | 32 | if (undoable(op, now)) return ""; |
| 32 | 33 | 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.undoneBy !== null) return ALREADY_UNDONE; | |
| 34 | 35 | if (!op.reversible) return `${clean(op.verb)} cannot be undone`; |
| 35 | 36 | if (now - Date.parse(op.createdAt) > UNDO_WINDOW_MS) |
| 36 | 37 | return "an undo is kept for a day, and this one's day is over"; |
| @@ -61,7 +62,7 @@ function rowsScreen(f: Session, ctx: Ctx, op: Operation): Screen { | ||
| 61 | 62 | render(area, s) { |
| 62 | 63 | const c = ctx.theme.c; |
| 63 | 64 | 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 | s.text(head!.x, head!.y, truncate(`${isAsksOperation(op) && full.ok ? receiptOf(full.value) : clean(op.verb)} · ${clean(op.query)}`, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 65 | 66 | if (rows.length === 0) { |
| 66 | 67 | emptyState(s, body!, c, { title: "No rows kept." }); |
| 67 | 68 | return; |
| @@ -114,7 +115,8 @@ export function bulkScreen(f: Session, ctx: Ctx): Screen { | ||
| 114 | 115 | ? await undoAsksBulk(f.app, f.user, op.id) |
| 115 | 116 | : await undoFleetOp(f.app, f.user, op.id); |
| 116 | 117 | cache = undefined; |
| 117 | ctx.toast(done.ok ? "undone" : done.error.message, done.ok ? "success" : "danger"); | |
| 118 | const said = !done.ok ? done.error.message : isAsksOperation(op) ? receiptOf(done.value) : "undone"; | |
| 119 | ctx.toast(said, done.ok ? "success" : "danger"); | |
| 118 | 120 | ctx.invalidate(); |
| 119 | 121 | }); |
| 120 | 122 | }; |
src/web/studio/asks_page.ts +4 −12
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | * (`actions/asks_bulk.ts`). `/asks` was its own screen; it lands here now. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { VERB_WORDS } from "../../actions/asks_words.ts"; | |
| 10 | 11 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 11 | 12 | import type { WaitingFor } from "../../core/waiting_for.ts"; |
| 12 | 13 | import { |
| @@ -15,6 +16,7 @@ import { | ||
| 15 | 16 | latestUndoable, |
| 16 | 17 | ownAsksOperation, |
| 17 | 18 | undoAsksBulk, |
| 19 | receiptOf, | |
| 18 | 20 | previewAsksBulk, |
| 19 | 21 | type Skipped, |
| 20 | 22 | } from "../../actions/asks_bulk.ts"; |
| @@ -475,7 +477,7 @@ export function needsYouSection( | ||
| 475 | 477 | last === undefined |
| 476 | 478 | ? null |
| 477 | 479 | : html`<form class="bar" method="post" action="/asks/bulk/${last.id}/undo" role="status"> |
| 478 | <span class="small">${VERB_WORDS[last.verb] ?? last.verb}: ${last.rows.filter((r) => r.outcome === "ok").length} done</span> | |
| 480 | <span class="small">${receiptOf(last)}</span> | |
| 479 | 481 | ${btn("Undo", { size: "sm" })} |
| 480 | 482 | <span class="muted small">kept for a day</span> |
| 481 | 483 | </form>`; |
| @@ -546,15 +548,6 @@ async function asksScreen(ctx: Ctx, top: AsksTop): Promise<Response> { | ||
| 546 | 548 | |
| 547 | 549 | // ---- many at once: select, preview, do it, undo — all on this screen ---- |
| 548 | 550 | |
| 549 | const VERB_WORDS: Readonly<Record<string, string>> = { | |
| 550 | dismiss: "Dismiss", | |
| 551 | decline: "Decline", | |
| 552 | mute: "Mute repository", | |
| 553 | ship: "Ship", | |
| 554 | close: "Close", | |
| 555 | replan: "Hand back to the planner", | |
| 556 | }; | |
| 557 | ||
| 558 | 551 | /** What the asks below can be given in one move. */ |
outdated
Leftover double blank line after removing VERB_WORDS. |
||
| 559 | 552 | const ASK_VERBS = ["decline", "ship", "close", "replan", "mute"] as const; |
| 560 | 553 | |
| @@ -644,9 +637,8 @@ const nothingDone = (message: string): AsksTop => ({ | ||
| 644 | 637 | function receiptTop(ctx: Ctx, opId: string): AsksTop | undefined { |
| 645 | 638 | const op = ownAsksOperation(ctx.app, ctx.auth.user, opId); |
| 646 | 639 | if (op === undefined) return undefined; |
| 647 | const verb = VERB_WORDS[op.verb] ?? op.verb; | |
| 648 | 640 | const done = op.rows.filter((r) => r.outcome === "ok").length; |
| 649 | const head = op.undoOf === null ? `${verb}: ${done} done` : `${verb} undone: ${done}`; | |
| 641 | const head = receiptOf(op); | |
| 650 | 642 | const undoable = op.reversible && op.undoOf === null && op.undoneBy === null && done > 0; |
| 651 | 643 | return { |
| 652 | 644 | banner: bulkCard( |
test/asks-bulk-parity.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * The same action has the same shape for a person and an agent (#10700): | |
| 3 | * settling three asks and undoing them says one sentence, from | |
| 4 | * src/actions/asks_words.ts, on the web form, in the terminal and over MCP; | |
| 5 | * each door's undo restores exactly those three, and a second undo is | |
| 6 | * refused in the same words. The asks are seeded directly: the doors | |
| 7 | * authenticate differently, the deed does not. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { applyAsksBulk, receiptOf } from "../src/actions/asks_bulk.ts"; | |
| 15 | import { ALREADY_UNDONE, NO_SUCH_OPERATION, receiptSentence } from "../src/actions/asks_words.ts"; | |
| 16 | import { openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { createRepo } from "../src/actions/repos.ts"; | |
| 18 | import { createApp, type App } from "../src/app.ts"; | |
| 19 | import { getIssueById, setAssignee } from "../src/core/issues.ts"; | |
| 20 | import type { Repo } from "../src/core/repos.ts"; | |
| 21 | import { createToken } from "../src/core/tokens.ts"; | |
| 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 23 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 28 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 29 | ||
| 30 | quietLogs(); | |
| 31 | ||
| 32 | const PASSWORD = "a-long-parity-passphrase"; | |
| 33 | const SETTLED = receiptSentence("close", 3, false); | |
| 34 | const UNDONE = receiptSentence("close", 3, true); | |
| 35 | ||
| 36 | let dataDir = ""; | |
| 37 | let app: App; | |
| 38 | let servers: Servers; | |
| 39 | let alice: User; | |
| 40 | let token = ""; | |
| 41 | let cookie = ""; | |
| 42 | let repoRow: Repo; | |
| 43 | let round = 0; | |
| 44 | ||
| 45 | /** Five open asks assigned to alice; the first three are the selection. */ | |
| 46 | function seed(): { picked: string[]; others: string[]; all: string[] } { | |
| 47 | round++; | |
| 48 | const ids: string[] = []; | |
| 49 | for (let i = 0; i < 5; i++) { | |
| 50 | const made = openIssue(app, alice, repoRow, { title: `Ask ${round}.${i}`, body: "" }); | |
| 51 | if (!made.ok) throw new Error(String(made.error)); | |
| 52 | setAssignee(app.db, made.value.id, alice.id); | |
| 53 | ids.push(made.value.id); | |
| 54 | } | |
| 55 | return { picked: ids.slice(0, 3), others: ids.slice(3), all: ids }; | |
| 56 | } | |
| 57 | ||
| 58 | const states = (ids: string[]): string[] => ids.map((id) => getIssueById(app.db, id)!.state); | |
| 59 | const items = (ids: string[]): string[] => ids.map((id) => `assigned:${id}`); | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | dataDir = await mkdtemp(join(tmpdir(), "forge-asks-parity-")); | |
| 63 | app = createApp({ dataDir, port: 0 }); | |
| 64 | servers = await serve(app); | |
| 65 | const made = await createUser(app.db, { | |
| 66 | origin: "seed", | |
| 67 | trusted: true, | |
| 68 | username: "alice", | |
| 69 | email: "[email protected]", | |
| 70 | password: PASSWORD, | |
| 71 | adminIfFirst: true, | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(made.error); | |
| 74 | alice = made.value; | |
| 75 | token = createToken(app.db, alice.id, "rw", "rw").token; | |
| 76 | const res = await fetch(`${servers.url}/signin`, { | |
| 77 | method: "POST", | |
| 78 | body: new URLSearchParams({ username: "alice", password: PASSWORD }), | |
| 79 | redirect: "manual", | |
| 80 | }); | |
| 81 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 82 | const repo = await createRepo(app, alice, { name: "parity", visibility: "public" }); | |
| 83 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 84 | repoRow = repo.value; | |
| 85 | }, 30_000); | |
| 86 | ||
| 87 | afterAll(async () => { | |
| 88 | await servers.stop(); | |
| 89 | app.close(); | |
| 90 | await rm(dataDir, { recursive: true, force: true }); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the sentence reads as it always has", () => { | |
| 94 | expect(SETTLED).toBe("Close: 3 done"); | |
| 95 | expect(UNDONE).toBe("Close undone: 3"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the web form settles three asks, undoes them, and refuses a second undo", async () => { | |
| 99 | const { picked, others } = seed(); | |
| 100 | const headers = { cookie: `${cookie}; forge_mode=studio`, origin: servers.url }; | |
| 101 | const post = async (path: string, fields: [string, string][]): Promise<{ page: string; location: string }> => { | |
| 102 | const res = await fetch(`${servers.url}${path}`, { method: "POST", headers, body: new URLSearchParams(fields), redirect: "manual" }); | |
| 103 | const location = res.headers.get("location") ?? ""; | |
| 104 | const target = res.status === 303 ? await fetch(`${servers.url}${location}`, { headers, redirect: "manual" }) : res; | |
| 105 | return { page: (await target.text()).replace(/\s+/g, " "), location }; | |
| 106 | }; | |
| 107 | const done = await post("/asks/bulk", [ | |
| 108 | ["action", "close"], | |
| 109 | ["apply", "1"], | |
| 110 | ["reason", "done"], | |
| 111 | ...items(picked).map((i): [string, string] => ["item", i]), | |
| 112 | ]); | |
| 113 | expect(done.page).toContain(`>${SETTLED}<`); | |
| 114 | expect(states(picked)).toEqual(["closed", "closed", "closed"]); | |
| 115 | expect(states(others)).toEqual(["open", "open"]); | |
| 116 | const op = done.location.match(/op=([^#]+)/)![1]!; | |
| 117 | const undone = await post(`/asks/bulk/${op}/undo`, []); | |
| 118 | expect(undone.page).toContain(`>${UNDONE}<`); | |
| 119 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 120 | expect(states(others)).toEqual(["open", "open"]); | |
| 121 | const again = await post(`/asks/bulk/${op}/undo`, []); | |
| 122 | expect(again.page).toContain(ALREADY_UNDONE); | |
| 123 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 124 | }); | |
| 125 | ||
| 126 | test("MCP asks_act settles three asks, undoes them, and refuses a second undo", async () => { | |
| 127 | const { picked, others } = seed(); | |
| 128 | const dry = await mcpTool(servers.url, token, "asks_act", { action: "close", items: items(picked), reason: "done" }); | |
| 129 | expect(dry.value.receipt).toBeUndefined(); | |
| 130 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 131 | const done = await mcpTool(servers.url, token, "asks_act", { action: "close", items: items(picked), reason: "done", dryRun: false }); | |
| 132 | expect(done.value.receipt).toBe(SETTLED); | |
| 133 | expect(states(picked)).toEqual(["closed", "closed", "closed"]); | |
| 134 | expect(states(others)).toEqual(["open", "open"]); | |
| 135 | const planned = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: done.value.id }); | |
| 136 | expect(planned.value.wouldUndo).toBe(done.value.id); | |
| 137 | expect(states(picked)).toEqual(["closed", "closed", "closed"]); | |
| 138 | const undone = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: done.value.id, dryRun: false }); | |
| 139 | expect(undone.value.receipt).toBe(UNDONE); | |
| 140 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 141 | expect(states(others)).toEqual(["open", "open"]); | |
| 142 | const again = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: done.value.id, dryRun: false }); | |
| 143 | expect(again.error).toContain(ALREADY_UNDONE); | |
| 144 | }); | |
| 145 | ||
| 146 | test("MCP asks_act plans no undo for an operation that is not there", async () => { | |
| 147 | const planned = await mcpTool(servers.url, token, "asks_act", { action: "undo", operation: "no-such-operation" }); | |
| 148 | expect(planned.value).toBeUndefined(); | |
| 149 | expect(planned.error).toContain(NO_SUCH_OPERATION); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the terminal's bulk screen names the same receipt and undoes in the same words", async () => { | |
| 153 | const { picked, others } = seed(); | |
| 154 | const op = await applyAsksBulk(app, alice, "close", items(picked), "done"); | |
| 155 | if (!op.ok) throw new Error(op.error.message); | |
| 156 | expect(receiptOf(op.value)).toBe(SETTLED); | |
| 157 | expect(states(picked)).toEqual(["closed", "closed", "closed"]); | |
| 158 | const terminal = headlessTerminal(140, 32); | |
| 159 | const { app: tui } = createForgeApp( | |
| 160 | app, | |
| 161 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: getUserByName(app.db, "alice")! }, | |
| 162 | { terminal, exitHooks: false }, | |
| 163 | ); | |
| 164 | tui.start(); | |
| 165 | const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 166 | const press = async (...keys: string[]) => { | |
| 167 | keys.forEach((k) => key(k)); | |
| 168 | await settleApp(tui); | |
| 169 | }; | |
| 170 | try { | |
| 171 | key("k", true); | |
| 172 | await settleApp(tui); | |
| 173 | for (const ch of "Bulk actions") key(ch); | |
| 174 | key("enter"); | |
| 175 | await settleApp(tui); | |
| 176 | await press("enter"); | |
| 177 | expect(tui.screen.toText()).toContain(SETTLED); | |
| 178 | await press("esc"); | |
| 179 | await press("u"); | |
| 180 | await settleUntil(tui, UNDONE); | |
| 181 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 182 | expect(states(others)).toEqual(["open", "open"]); | |
| 183 | await press("j", "u"); | |
| 184 | expect(tui.screen.toText()).toContain(ALREADY_UNDONE); | |
| 185 | expect(states(picked)).toEqual(["open", "open", "open"]); | |
| 186 | } finally { | |
| 187 | tui.stop(); | |
| 188 | } | |
| 189 | }); |
test/asks-words.test.ts +1 −1
| @@ -16,7 +16,7 @@ describe("asks words", () => { | ||
| 16 | 16 | test(`${file} imports the sentence and types no copy of it`, () => { |
| 17 | 17 | const source = text(file); |
| 18 | 18 | expect(source).not.toContain(SIGN_IN_TO_ACT); |
| 19 | expect(source).toContain('import { SIGN_IN_TO_ACT } from "./asks_words.ts";'); | |
| 19 | expect(source).toMatch(/import \{[^}]*\bSIGN_IN_TO_ACT\b[^}]*\} from "\.\/asks_words\.ts";/); | |
| 20 | 20 | }); |
| 21 | 21 | } |
| 22 | 22 | }); |
The dry run echoes any string as wouldUndo without looking the operation up. Resolve it with the owner check and return not_found when it is not the caller's; test it.