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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10700-settling-many-asks-at-once-says-the-same → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/10700-settling-many-asks-at-once-says-the-same was merged into main as e3a5214.

Review

Sign in to review this change.
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`);
161161 `items` are ask ids (`assigned:<id>` for something assigned to you; `issue:<id>`
162162 still names one). The list swaps in place and the top bar's count follows. One undo link
163163 (`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.
165167
166168 ## The composer keeps your draft
167169
src/actions/asks_bulk.ts +9 −2
@@ -12,7 +12,6 @@ import type { App } from "../app.ts";
1212 import { listOperations, type Operation } from "../core/fleet_ops.ts";
1313 import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
15 import { NO_SUCH_OPERATION } from "./asks_words.ts";
1615 import { askTargetsFor } from "../web/studio/asks.ts";
1716 import {
1817 applyFleetIds,
@@ -24,7 +23,7 @@ import {
2423 type FleetError,
2524 type Plan,
2625 } 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";
2827
2928 /**
3029 * What a bulk action on asks may be: the fleet verb each one is. Decline is
@@ -195,3 +194,11 @@ export async function undoAsksBulk(
195194 return err({ code: "not_found", message: NO_SUCH_OPERATION });
196195 return undoFleetOp(app, actor, op.id);
197196 }
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";
1616
1717 /** A milestone that is not here, or not readable by the one asking. */
1818 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 {
5858 } from "../fleet/selection.ts";
5959 import { digestOf } from "../fleet/digest.ts";
6060 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";
6262
6363 export interface FleetError {
6464 readonly code:
@@ -648,7 +648,7 @@ export async function undoFleetOp(
648648 if (keepsADay(op) && Date.now() - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
649649 return fail("expired", "an undo is kept for a day, and this one's day is over");
650650 if (!claimUndo(app.db, op.id))
651 return fail("already_undone", "this operation was undone already");
651 return fail("already_undone", ALREADY_UNDONE);
652652 let undone: Operation | undefined;
653653 try {
654654 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";
23 import { mutedRepos, removeMutes } from "../../../core/interactions.ts";
34 import { good, bad, repoArg, type Tool } from "../shared.ts";
45
56 export const tool: Tool = {
67 name: "asks_act",
78 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`)",
910 inputSchema: {
1011 type: "object",
1112 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"] },
1314 items: { type: "array", items: { type: "string" }, maxItems: 100 },
1415 repo: { type: "string" },
16 operation: { type: "string" },
1517 reason: { type: "string", maxLength: 300 },
1618 dryRun: { type: "boolean" },
1719 },
1820 required: ["action"],
1921 },
2022 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`",
2224 writes: true,
2325 dryByDefault: true,
2426 async run(ctx, user, args) {
@@ -33,10 +35,23 @@ export const tool: Tool = {
3335 removeMutes(ctx.app.db, user.id, [`repo:${repo.id}`]);
3436 return good({ listening: [name] });
3537 }
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");
outdated

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.

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);
4156 },
4257 };
src/tui/screens/bulk.ts +6 −4
@@ -9,7 +9,8 @@
99 */
1010
1111 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";
1314 import type { Operation } from "../../core/fleet_ops.ts";
1415 import type { Ctx, Screen } from "../app/app.ts";
1516 import { binding } from "../app/keys.ts";
@@ -30,7 +31,7 @@ const show = (v: unknown): string => clean(v === null || v === undefined ? "—"
3031 export function whyNot(op: Operation, now = Date.now()): string {
3132 if (undoable(op, now)) return "";
3233 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;
3435 if (!op.reversible) return `${clean(op.verb)} cannot be undone`;
3536 if (now - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
3637 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 {
6162 render(area, s) {
6263 const c = ctx.theme.c;
6364 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);
6566 if (rows.length === 0) {
6667 emptyState(s, body!, c, { title: "No rows kept." });
6768 return;
@@ -114,7 +115,8 @@ export function bulkScreen(f: Session, ctx: Ctx): Screen {
114115 ? await undoAsksBulk(f.app, f.user, op.id)
115116 : await undoFleetOp(f.app, f.user, op.id);
116117 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");
118120 ctx.invalidate();
119121 });
120122 };
src/web/studio/asks_page.ts +4 −12
@@ -7,6 +7,7 @@
77 * (`actions/asks_bulk.ts`). `/asks` was its own screen; it lands here now.
88 */
99
10 import { VERB_WORDS } from "../../actions/asks_words.ts";
1011 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1112 import type { WaitingFor } from "../../core/waiting_for.ts";
1213 import {
@@ -15,6 +16,7 @@ import {
1516 latestUndoable,
1617 ownAsksOperation,
1718 undoAsksBulk,
19 receiptOf,
1820 previewAsksBulk,
1921 type Skipped,
2022 } from "../../actions/asks_bulk.ts";
@@ -475,7 +477,7 @@ export function needsYouSection(
475477 last === undefined
476478 ? null
477479 : 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>
479481 ${btn("Undo", { size: "sm" })}
480482 <span class="muted small">kept for a day</span>
481483 </form>`;
@@ -546,15 +548,6 @@ async function asksScreen(ctx: Ctx, top: AsksTop): Promise<Response> {
546548
547549 // ---- many at once: select, preview, do it, undo — all on this screen ----
548550
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
558551 /** What the asks below can be given in one move. */
outdated

Leftover double blank line after removing VERB_WORDS.

559552 const ASK_VERBS = ["decline", "ship", "close", "replan", "mute"] as const;
560553
@@ -644,9 +637,8 @@ const nothingDone = (message: string): AsksTop => ({
644637 function receiptTop(ctx: Ctx, opId: string): AsksTop | undefined {
645638 const op = ownAsksOperation(ctx.app, ctx.auth.user, opId);
646639 if (op === undefined) return undefined;
647 const verb = VERB_WORDS[op.verb] ?? op.verb;
648640 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);
650642 const undoable = op.reversible && op.undoOf === null && op.undoneBy === null && done > 0;
651643 return {
652644 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", () => {
1616 test(`${file} imports the sentence and types no copy of it`, () => {
1717 const source = text(file);
1818 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";/);
2020 });
2121 }
2222 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs