Public: anyone can read this; the crew can change it.

`/fleet` applies one verb to the rows it selects, with a preview first and Undo after #10147

Merged opened this

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

Merged

scout/forge:scout/10133-fleet-applies-one-verb-to-the-rows-it-se → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/10133-fleet-applies-one-verb-to-the-rows-it-se was merged into main as 487d8ea.

Review

Sign in to review this change.
12 files changed +644 −7
docs/features/fleet.md +9
@@ -8,11 +8,20 @@ Before a fleet operation acts, see the rows its query line selects. There are tw
88 - Click a column header to sort by it, and click again to reverse. This works through `?sort=<column>` and `?sort=-<column>` links, so no JavaScript is needed.
99 - A line that does not parse shows its reason beside the field, with status 200, and selects nothing.
1010
11 **Acting on the rows (web):** under the table, a signed-in person picks a verb for the kind and a value, and chooses Preview (`src/web/pages/fleet-ops.ts`, `src/web/fleet-act.ts`).
12
13 - Preview is a same-origin `POST /fleet` with `step=preview`. It shows each row's change, and which rows are unchanged or refused, each with its sentence in a muted line. Nothing is written.
14 - Apply is `POST /fleet` with `step=apply`. It carries a digest of the rows the preview showed (`src/fleet/digest.ts`) and runs `applyPreviewed`: if the rows now plan differently, nothing is written and a muted line asks for a new preview. Otherwise it lands on `GET /fleet/ops/:id`, the result.
15 - The result has Undo, `POST /fleet/ops/:id/undo`, which runs `undoFleetOp` while the operation is within `UNDO_WINDOW_MS` (a day) and not yet undone.
16 - No JavaScript is needed. A cross-origin POST is refused with 403. A signed-out visitor and an agent are not offered the form.
17 - A value reads as a flag (`true`, `false`), a number, or text, whichever the verb accepts. Leave it empty for a verb that takes none.
18
1119 **In the terminal:** "Fleet rows" in the palette (`src/tui/screens/fleet.ts`).
outdated

Both doors: the terminal has no way to preview/apply/undo; this section must gain the TUI how-to once the screen is added.

1220
1321 - It asks for the query line and shows the same table.
1422 - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back.
1523 - A line that does not parse keeps the prompt open with its reason.
24 - Signed in as a person (a guest or an agent is not offered these): `v` asks for a verb and its value (`label urgent`), then shows each row's change in a dialog. Apply writes through `applyPreviewed` with the digest of what the dialog showed. `u` undoes the last result. Same operations and refusals as the page (`src/tui/screens/fleet.ts`).
1625
1726 **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`).
1827
src/actions/fleet.ts +21
@@ -56,6 +56,7 @@ import {
5656 selectRows,
5757 type FleetKind,
5858 } from "../fleet/selection.ts";
59 import { digestOf } from "../fleet/digest.ts";
5960 import { VERBS, type Target, type Verb } from "../fleet/verbs.ts";
6061 import { NO_SUCH_OPERATION } from "./asks_words.ts";
6162
@@ -555,6 +556,26 @@ export async function applyFleetOp(
555556 return ok(await carryOutAndRecord(app, person.value, plan.value));
556557 }
557558
559 /**
560 * Apply only what was previewed: plan again, and if the rows now plan
561 * differently from the `digest` the preview carried, write nothing.
562 */
563 export async function applyPreviewed(
564 app: App,
565 actor: User | undefined,
566 query: string,
567 action: unknown,
568 digest: string,
569 ): Promise<Result<Operation, FleetError>> {
570 const person = requirePerson(actor);
571 if (!person.ok) return person;
572 const plan = makePlan(app, person.value, query, action);
573 if (!plan.ok) return plan;
574 if (digestOf(plan.value.rows) !== digest)
575 return fail("expired", "The rows changed since the preview. Preview again to see them.");
576 return ok(await carryOutAndRecord(app, person.value, plan.value));
577 }
578
558579 /** How long a bulk action on asks can be undone: a day. */
559580 export const UNDO_WINDOW_MS = 86_400_000;
560581
src/fleet/digest.ts added +21
@@ -0,0 +1,21 @@
1 /**
2 * What a person saw when they previewed: one short digest of the rows, what
3 * each would become, and what was said of it. Apply carries it back and is
4 * refused if the rows now plan differently, so nothing is written that was
5 * not shown. Plain data in, a string out.
6 */
7
8 import type { FleetValue } from "../core/fleet_ops.ts";
9
10 export interface Shown {
11 readonly targetId: string;
12 readonly after: FleetValue;
13 readonly outcome: string;
14 }
15
16 /** A digest of the plan's rows, in the order given. */
17 export function digestOf(rows: readonly Shown[]): string {
18 const hasher = new Bun.CryptoHasher("sha256");
19 for (const r of rows) hasher.update(`${r.targetId}\n${r.outcome}\n${JSON.stringify(r.after)}\n`);
20 return hasher.digest("hex").slice(0, 32);
21 }
src/fleet/reading.ts added +31
@@ -0,0 +1,31 @@
1 /**
2 * What a typed value means to a verb, for the doors that take text (the web
3 * form, the terminal prompt): nothing, a flag, a number, or the text itself,
4 * whichever the verb accepts first.
5 */
6
7 import type { App } from "../app.ts";
8 import type { FleetValue } from "../core/fleet_ops.ts";
9 import { VERBS } from "./verbs.ts";
10
11 /** The readings of a text, most specific first. */
12 export function valuesOf(text: string): FleetValue[] {
13 const t = text.trim();
14 if (t === "") return [null];
15 const out: FleetValue[] = [];
16 if (t === "true" || t === "false") out.push(t === "true");
17 else if (/^-?\d+(\.\d+)?$/.test(t)) out.push(Number(t));
18 out.push(t);
19 return out;
20 }
21
22 /** The action for a verb and a text: the first reading the verb accepts. */
23 export function actionOf(app: App, verb: string, text: string): { verb: string; value: FleetValue } {
24 const known = Object.hasOwn(VERBS, verb) ? VERBS[verb] : undefined;
25 const options = valuesOf(text);
26 const value =
27 known === undefined
28 ? options[0]!
29 : (options.find((v) => known.parse(app, v).ok) ?? options[0]!);
30 return { verb, value: value ?? null };
31 }
src/http/server.ts +4
@@ -26,6 +26,7 @@ import { prerenderComments } from "../web/prerender.ts";
2626 import { systemPage, systemStream } from "../web/pages/system.ts";
2727 import { contributePage } from "../web/pages/contribute.ts";
2828 import { pipelinePage } from "../web/pages/pipeline.ts";
29 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
2930 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3031 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3132 import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
@@ -924,6 +925,9 @@ export function buildRouter(): Router<Handler> {
924925 router.add("GET", "/resilience/nightly/:job", nightlyOutputPage);
925926 // A selection's spend and runs over time; literal, above /:owner.
926927 router.add("GET", "/fleet", fleetPage);
928 router.add("POST", "/fleet", fleetAct);
929 router.add("GET", "/fleet/ops/:id", fleetReceipt);
930 router.add("POST", "/fleet/ops/:id/undo", fleetUndo);
927931 router.add("GET", "/fleet/graph", fleetGraphPage);
928932 router.add("GET", "/fleet/graph/live", fleetGraphLive);
929933 router.add("GET", "/fleet/graph.csv", fleetGraphCsv);
src/tui/screens/fleet.ts +89 −3
@@ -4,9 +4,17 @@
44 * (`fleet/rows.ts` `viewFleet`), so the grammar and who sees which row are
55 * `fleet/selection.ts`'s, never this screen's; the same columns and sort
66 * (`fleet/columns.ts`). Keys: `/` a new query line, `s` sort by the next
7 * column, `r` reverse it, `j`/`k` scroll, `esc` back.
7 * column, `r` reverse it, `j`/`k` scroll, `esc` back. A signed-in person (not
8 * an agent, as on the page) also has `v`: a verb and its value, the preview
9 * of each row's change, Apply, and `u` to undo the last result. The writes
10 * are `actions/fleet.ts`'s, as the page's are.
811 */
912
13 import type { Operation } from "../../core/fleet_ops.ts";
14 import { applyPreviewed, previewFleetOp, undoFleetOp, type Plan } from "../../actions/fleet.ts";
15 import { digestOf } from "../../fleet/digest.ts";
16 import { actionOf } from "../../fleet/reading.ts";
17
1018 import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts";
1119 import { viewFleet, type FleetView } from "../../fleet/rows.ts";
1220 import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, type FleetKind } from "../../fleet/selection.ts";
@@ -16,7 +24,7 @@ import { binding } from "../app/keys.ts";
1624 import type { Session } from "../session.ts";
1725 import { style } from "../term/style.ts";
1826 import { pad, truncate, wrap } from "../term/width.ts";
19 import { promptScreen, Scroll } from "./common.ts";
27 import { confirmScreen, later, promptScreen, Scroll } from "./common.ts";
2028
2129 /** No column is padded past this, so a long title does not push the rest away. */
2230 const CELL_MAX = 40;
@@ -66,6 +74,21 @@ export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort {
6674 return { key: keys[(at + 1) % keys.length]!, descending: false };
6775 }
6876
77 /** A row's change in one line: label, before → after, and what becomes of it. */
78 export function planLines(plan: Plan, width: number, most = 12): string[] {
79 const word = (v: unknown) => (v === null ? "nothing" : Array.isArray(v) ? v.join(", ") || "none" : String(v));
80 const rows = plan.rows.slice(0, most).map((r) =>
81 truncate(
82 `${r.label}: ${word(r.before)} -> ${word(r.after)} ${r.outcome === "change" ? "" : r.outcome}${r.reason === "" ? "" : ` - ${r.reason}`}`.trimEnd(),
83 width,
84 ),
85 );
86 return plan.rows.length > most ? [...rows, `and ${plan.rows.length - most} more`] : rows;
87 }
88
89 /** A person (not a guest, not an agent) may act, as on the page. */
90 export const mayAct = (user: Session["user"]): boolean => user !== undefined && user.kind !== "agent";
91
6992 /** The screen, and the prompt that writes its query line. */
7093 export function fleetScreen(
7194 session: Session,
@@ -77,10 +100,13 @@ export function fleetScreen(
77100 let view: FleetView | undefined = line === "" ? undefined : viewFleet(session.db, session.user, line);
78101 let sort: Sort | undefined;
79102 let lines: string[] = [];
103 let note = "";
104 let last: Operation | undefined;
80105 const screen: Screen = {
81106 title: "Fleet",
82107 keys: [
83108 binding(["/"], "query"),
109 ...(mayAct(session.user) ? [binding(["v"], "verb"), binding(["u"], "undo")] : []),
84110 binding(["s"], "sort"),
85111 binding(["r"], "reverse"),
86112 binding(["j", "k"], "scroll"),
@@ -94,7 +120,8 @@ export function fleetScreen(
94120 : view.ok
95121 ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}`
96122 : `${query} ${view.error}`;
97 s.text(area.x + 1, area.y, truncate(head, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w);
123 const shown = note === "" ? head : `${head} ${note}`;
124 s.text(area.x + 1, area.y, truncate(shown, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w);
98125 if (view?.ok !== true) {
99126 // No rows to show: the grammar fills the space, as it sits under the web form.
100127 grammarLines(Math.max(20, area.w - 2)).forEach((text, i) => {
@@ -118,12 +145,71 @@ export function fleetScreen(
118145 if (e.ctrl || e.alt) return scroll.key(e, Math.max(0, lines.length - 1));
119146 if (e.key === "esc" || e.key === "q") ctx.pop();
120147 else if (e.key === "/") ask();
148 else if (e.key === "v" && view?.ok && mayAct(session.user)) verb();
149 else if (e.key === "u" && last !== undefined && mayAct(session.user)) undo();
121150 else if (e.key === "s" && view?.ok) sort = nextSort(view.kind, sort);
122151 else if (e.key === "r" && view?.ok && sort !== undefined) sort = { ...sort, descending: !sort.descending };
123152 else return scroll.key(e, Math.max(0, lines.length - 1));
124153 return true;
125154 },
126155 };
156 const refresh = () => {
157 if (query !== "") view = viewFleet(session.db, session.user, query);
158 };
159 const undo = () => {
160 const op = last;
161 if (op === undefined) return;
162 ctx.push(
163 confirmScreen(ctx, screen, {
164 title: "Undo",
165 body: [`Put back what ${op.verb} changed.`],
166 actions: [{ label: "Undo" }, { label: "Cancel", variant: "outline" }],
167 onChoose: (i) => {
168 if (i !== 0) return;
169 void undoFleetOp(session.app, session.user, op.id).then((r) => {
170 note = r.ok ? "Undone." : r.error.message;
171 if (r.ok) last = undefined;
172 refresh();
173 });
174 },
175 }),
176 );
177 };
178 const verb = () =>
179 ctx.push(
180 promptScreen(ctx, screen, {
181 title: "Verb",
182 label: "a verb, then its value (some take none)",
183 placeholder: "label urgent",
184 hint: "Nothing is written until you apply.",
185 onSubmit: (text) => {
186 const [name = "", ...rest] = text.trim().split(/\s+/);
187 const value = rest.join(" ");
188 if (name === "") return "name a verb";
189 const action = actionOf(session.app, name, value);
190 const plan = previewFleetOp(session.app, session.user, query, action);
191 if (!plan.ok) return plan.error.message;
192 const p = plan.value;
193 const n = p.counts.change;
194 return later(ctx, () =>
195 confirmScreen(ctx, screen, {
196 title: `Preview: ${p.verb}`,
197 body: [...planLines(p, 70), "", n === 0 ? "Nothing to apply." : "Nothing is written until you apply."],
198 actions: n === 0 ? [{ label: "Close" }] : [{ label: `Apply to ${n} ${n === 1 ? "row" : "rows"}` }, { label: "Cancel", variant: "outline" }],
199 width: 76,
200 onChoose: (i) => {
201 if (i !== 0 || n === 0) return;
202 void applyPreviewed(session.app, session.user, query, action, digestOf(p.rows)).then((r) => {
203 note = r.ok ? "Applied. u undoes." : r.error.message;
204 if (r.ok) last = r.value;
205 refresh();
206 });
207 },
208 }),
209 );
210 },
211 }),
212 );
127213 const ask = () =>
128214 ctx.push(
129215 promptScreen(ctx, screen, {
src/web/fleet-act.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * The write side of `/fleet`, as HTML: the verb form under the table, the
3 * preview of what applying would do, and the receipt with Undo. Pure: plans
4 * and operations in, HTML out. The writes are `actions/fleet.ts`'s.
5 */
6
7 import type { FleetValue, Operation } from "../core/fleet_ops.ts";
8 import type { Plan } from "../actions/fleet.ts";
9 import { UNDO_WINDOW_MS } from "../actions/fleet.ts";
10 import { digestOf } from "../fleet/digest.ts";
11 import { VERBS } from "../fleet/verbs.ts";
12 import type { FleetKind } from "../fleet/selection.ts";
13 import { html, type Html } from "./html.ts";
14 import { btn, field, selectField } from "./ui.ts";
15
16 /** A value in words. */
17 export const valueWords = (value: FleetValue): string =>
18 value === null
19 ? "nothing"
20 : Array.isArray(value)
21 ? value.length === 0
22 ? "none"
23 : value.join(", ")
24 : String(value);
25
26 /** The verbs a kind takes, by name. */
27 export const verbsOf = (kind: FleetKind): string[] =>
28 Object.keys(VERBS).filter((v) => VERBS[v]!.kind === kind);
29
30 /** The form that asks for a verb and its value; posts to the preview. */
31 export function actForm(kind: FleetKind, q: string, chosen = "", value = ""): Html {
32 return html`<form method="post" action="/fleet" class="field-row mb" data-act>
33 <input type="hidden" name="q" value="${q}" />
34 <input type="hidden" name="step" value="preview" />
35 ${selectField({
36 label: "Verb",
37 name: "verb",
38 id: "fleet-verb",
39 options: verbsOf(kind).map((v) => ({ value: v, label: v, selected: v === chosen })),
40 })}
41 ${field({
42 label: "Value",
43 name: "value",
44 id: "fleet-value",
45 value,
46 autocomplete: "off",
47 mono: true,
48 spellcheck: false,
49 hint: "Some verbs take none.",
50 })}
51 ${btn("Preview", { type: "submit", variant: "secondary" })}
52 </form>`;
53 }
54
55 const outcomeWords: Record<string, string> = {
56 change: "will change",
57 unchanged: "unchanged",
58 refused: "refused",
59 ok: "changed",
60 failed: "failed",
61 };
62
63 interface Line {
64 label: string;
65 before: FleetValue;
66 after: FleetValue;
67 outcome: string;
68 reason: string;
69 }
70
71 function lines(rows: readonly Line[]): Html {
72 return html`<ul class="list" data-fleet-op>${rows.map(
73 (r) => html`<li data-outcome="${r.outcome}">
74 <span>${r.label}</span>
75 <span class="mono">${valueWords(r.before)} → ${valueWords(r.after)}</span>
76 <span class="muted">${outcomeWords[r.outcome] ?? r.outcome}${r.reason === "" ? null : ` — ${r.reason}`}</span>
77 </li>`,
78 )}</ul>`;
79 }
80
81 /** The dry run: each row's change, and one button to apply it. Nothing is written yet. */
82 export function previewSection(plan: Plan, rawValue: string): Html {
83 const { change } = plan.counts;
84 return html`<section data-preview>
85 <h2>Preview: ${plan.verb}</h2>
86 <p class="muted">Nothing is written until you apply.</p>
87 ${lines(plan.rows)}
88 ${change === 0
89 ? html`<p class="muted">Nothing to apply.</p>`
90 : html`<form method="post" action="/fleet" class="form-actions">
91 <input type="hidden" name="q" value="${plan.query}" />
92 <input type="hidden" name="step" value="apply" />
93 <input type="hidden" name="verb" value="${plan.verb}" />
94 <input type="hidden" name="value" value="${rawValue}" />
95 <input type="hidden" name="digest" value="${digestOf(plan.rows)}" />
96 ${btn(`Apply to ${change} ${change === 1 ? "row" : "rows"}`, { type: "submit" })}
97 </form>`}
98 </section>`;
99 }

Principle 1: the summary sentence repeats the preview table and the button label ('Apply to N rows'). Say it once.

100
101 /** Whether an Undo is still offered for a receipt. */
102 export const undoable = (op: Operation, now = Date.now()): boolean =>
103 op.reversible &&
104 op.undoOf === null &&
105 op.undoneBy === null &&
106 op.rows.some((r) => r.outcome === "ok") &&
107 now - Date.parse(op.createdAt) <= UNDO_WINDOW_MS;
108
109 /** The result of an operation, with Undo while it can be undone. */
110 export function receiptSection(op: Operation, canUndo: boolean): Html {
111 return html`<section data-receipt="${op.id}">
112 <h2>${op.undoOf === null ? "Applied" : "Undone"}: ${op.verb}</h2>
outdated

Principle 1: the receipt counts repeat what the table beneath shows; keep one.

113 ${op.undoneBy === null ? null : html`<p class="muted">This was undone.</p>`}
114 ${lines(op.rows)}
115 ${canUndo
116 ? html`<form method="post" action="/fleet/ops/${op.id}/undo" class="form-actions">
117 ${btn("Undo", { type: "submit", variant: "outline" })}
118 </form>`
119 : null}
120 </section>`;
121 }
src/web/pages/fleet-ops.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * `/fleet` writes: `POST /fleet` previews (`step=preview`) or applies
3 * (`step=apply`) one verb over the rows a query line selects,
4 * `GET /fleet/ops/:id` is the receipt, `POST /fleet/ops/:id/undo` undoes it.
5 * The operations are `actions/fleet.ts`'s; nothing here writes. Every POST
6 * is same-origin (`readForm`), and only a signed-in person acts.
7 */
8
9 import {
10 applyPreviewed,
11 getFleetOp,
12 previewFleetOp,
13 undoFleetOp,
14 } from "../../actions/fleet.ts";
15 import { actionOf } from "../../fleet/reading.ts";
16 import type { Ctx } from "../../http/context.ts";
17 import { notFoundPage, signInFirst } from "../chrome.ts";
18 import { previewSection, receiptSection, undoable } from "../fleet-act.ts";
19 import { field, readForm } from "../form.ts";
20 import { html } from "../html.ts";
21 import { flashTo, page } from "../layout.ts";
22 import { canAct, renderFleet } from "./fleet.ts";
23
24 /** POST /fleet */
25 export async function fleetAct(ctx: Ctx): Promise<Response> {
26 if (!ctx.auth.user) return signInFirst(ctx);
27 if (!canAct(ctx)) return notFoundPage(ctx);
28 const form = await readForm(ctx, "rw");
29 if (form instanceof Response) return form;
30 const q = field(form, "q").slice(0, 2000);
31 const verb = field(form, "verb").slice(0, 40);
32 const text = field(form, "value").slice(0, 2000);
33 const action = actionOf(ctx.app, verb, text);
34 if (field(form, "step") === "apply") {
35 const done = await applyPreviewed(ctx.app, ctx.auth.user, q, action, field(form, "digest").slice(0, 64));
36 if (!done.ok) return refusal(ctx, q, verb, text, done.error.message);
37 return flashTo(ctx, `/fleet/ops/${done.value.id}`, { message: "Applied." });
38 }
39 const plan = previewFleetOp(ctx.app, ctx.auth.user, q, action);
40 if (!plan.ok) return refusal(ctx, q, verb, text, plan.error.message);
41 return renderFleet(ctx, q, { html: previewSection(plan.value, text), verb, value: text });
42 }
43
44 function refusal(ctx: Ctx, q: string, verb: string, value: string, message: string): Response {
45 return renderFleet(ctx, q, {
46 html: html`<p class="muted" data-refused>${message}</p>`,
47 verb,
48 value,
49 });
50 }
outdated

Apply re-selects rows from q instead of the previewed set; rows can differ from what the person saw. Pin the previewed ids (digest) and refuse on mismatch.

51
52 /** GET /fleet/ops/:id */
53 export function fleetReceipt(ctx: Ctx): Response {
54 if (!ctx.auth.user) return signInFirst(ctx);
55 const found = getFleetOp(ctx.app, ctx.auth.user, ctx.params.id ?? "");
56 if (!found.ok) return notFoundPage(ctx);
57 const op = found.value;
58 return page({
59 ctx,
60 title: "Fleet result",
61 wide: true,
62 body: html`<div class="page-head"><h1>Fleet result</h1>
63 <p class="muted"><code>${op.query}</code></p></div>
64 ${receiptSection(op, canAct(ctx) && undoable(op))}
65 <p><a href="/fleet?${new URLSearchParams({ q: op.query }).toString()}">Back to the rows</a></p>`,
66 });
67 }
68
69 /** POST /fleet/ops/:id/undo */
70 export async function fleetUndo(ctx: Ctx): Promise<Response> {
71 if (!ctx.auth.user) return signInFirst(ctx);
72 if (!canAct(ctx)) return notFoundPage(ctx);
73 const form = await readForm(ctx, "rw");
74 if (form instanceof Response) return form;
75 const id = ctx.params.id ?? "";
76 const undone = await undoFleetOp(ctx.app, ctx.auth.user, id);
77 if (!undone.ok)
78 return flashTo(ctx, `/fleet/ops/${encodeURIComponent(id)}`, {
79 tone: "danger",
80 message: undone.error.message,
81 });
82 return flashTo(ctx, `/fleet/ops/${undone.value.id}`, { message: "Undone." });
83 }
src/web/pages/fleet.ts +20 −3
@@ -12,14 +12,23 @@ import { viewFleet, type FleetView } from "../../fleet/rows.ts";
1212 import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, MAX_ROWS } from "../../fleet/selection.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { fleetTable } from "../fleet-table.ts";
15 import { html } from "../html.ts";
15 import { actForm } from "../fleet-act.ts";
16 import { html, type Html } from "../html.ts";
1617 import { page } from "../layout.ts";
1718 import { btn, field } from "../ui.ts";
1819
1920 /** GET /fleet */
2021 export function fleetPage(ctx: Ctx): Response {
2122 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 const q = (ctx.url.searchParams.get("q") ?? "").slice(0, 2000);
23 return renderFleet(ctx, (ctx.url.searchParams.get("q") ?? "").slice(0, 2000));
24 }
25
26 /** The page for a query line; `below` is a preview or a receipt, drawn under the form. */
27 export function renderFleet(
28 ctx: Ctx,
29 q: string,
30 below?: { html: Html; verb?: string; value?: string },
31 ): Response {
2332 const view: FleetView | undefined =
2433 q.trim() === "" ? undefined : viewFleet(ctx.app.db, ctx.auth.user, q);
2534 const rendering = performance.now();
@@ -66,8 +75,16 @@ export function fleetPage(ctx: Ctx): Response {
6675 : html`<p class="muted" data-count="${shown.rows.length}">
6776 ${shown.rows.length} ${shown.rows.length === 1 ? "row" : "rows"}
6877 </p>
69 ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)}`}`,
78 ${shown.rows.length === 0 ? null : fleetTable(shown.kind, q, shown.rows, sort)}
79 ${shown.rows.length === 0 || !canAct(ctx)
80 ? null
81 : actForm(shown.kind, q, below?.verb, below?.value)}`}
82 ${below?.html ?? null}`,
7083 });
7184 notePhase(ctx.request, "render", performance.now() - rendering);
7285 return response;
7386 }
87
88 /** A signed-in person may act; a signed-out visitor, and an agent, only read. */
89 export const canAct = (ctx: Ctx): boolean =>
90 ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent";
test/fleet-act.test.ts added +205
@@ -0,0 +1,205 @@
1 /**
2 * `/fleet` writes (#10133): a same-origin POST previews a verb over the
3 * selected rows and writes nothing; apply writes through `applyFleetOp` and
4 * lands on a receipt; Undo calls `undoFleetOp`. A cross-origin POST is
5 * refused and a signed-out reader is not offered the form.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createAgent } from "../src/actions/crew.ts";
14 import { createLabel, createRepo } from "../src/actions/repos.ts";
15 import { createSession } from "../src/core/sessions.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { labelsFor } from "../src/core/issues.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { promote } from "./helpers/members.ts";
21
22 describe("fleet verbs on the web", () => {
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let cookie: string;
27 const ids: string[] = [];
28 const q = "issue repo:alice/engine state:open";
29
30 const person = async (username: string, adminIfFirst = false) => {
31 const made = await createUser(app.db, {
32 origin: "seed", trusted: true, username, allowReserved: true,
33 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
34 });
35 if (!made.ok) throw new Error(made.error);
36 promote(app.db, made.value);
37 return made.value;
38 };
39 const post = (path: string, body: Record<string, string>, headers: Record<string, string> = {}) =>
40 fetch(`${servers.url}${path}`, {
41 method: "POST",
42 redirect: "manual",
43 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded", ...headers },
44 body: new URLSearchParams(body),
45 });
46 /** Preview, then apply with the digest the preview carried: what a person's browser does. */
47 const apply = async (body: Record<string, string>, headers: Record<string, string> = {}) => {
48 const shown = await (await post("/fleet", { ...body, step: "preview" }, headers)).text();
49 const digest = /name="digest" value="([0-9a-f]+)"/.exec(shown)?.[1] ?? "";
50 return post("/fleet", { ...body, step: "apply", digest }, headers);
51 };
52 const labelled = () => ids.map((id) => labelsFor(app.db, id).map((l) => l.name));
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-act-"));
56 app = createApp({ dataDir, port: 0 });
57 servers = await serve(app);
58 await person("root", true);
59 const alice: User = await person("alice");
60 const repo = await createRepo(app, alice, { name: "engine", visibility: "public" });
61 if (!repo.ok) throw new Error(String(repo.error));
62 for (const n of [1, 2]) {
63 const made = openIssue(app, alice, repo.value, { title: `Task ${n}` });
64 if (!made.ok) throw new Error(String(made.error));
65 ids.push(made.value.id);
66 }
67 createLabel(app, alice, repo.value, "urgent", "ff0000");
68 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
69 }, 30_000);
70
71 afterAll(async () => {
72 servers.stop();
73 app.close();
74 await rm(dataDir, { recursive: true, force: true });
75 });
76
77 test("a reader is not offered the form; a writer is", async () => {
78 const path = `/fleet?${new URLSearchParams({ q })}`;
79 const reader = await (await fetch(`${servers.url}${path}`)).text();
80 expect(reader).toContain("data-count=\"2\"");
81 expect(reader).not.toContain("data-act");
82 const writer = await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
83 expect(writer).toContain("data-act");
84 expect(writer).toContain('value="label"');
85 });
86
87 test("a signed-out POST is not a write", async () => {
88 const res = await fetch(`${servers.url}/fleet`, {
89 method: "POST", redirect: "manual",
90 headers: { origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
91 body: new URLSearchParams({ q, step: "apply", verb: "label", value: "urgent" }),
92 });
93 expect([303, 401, 403]).toContain(res.status);
94 expect(labelled()).toEqual([[], []]);
95 });
96
97 test("a cross-origin POST is refused and writes nothing", async () => {
98 const res = await post("/fleet", { q, step: "apply", verb: "label", value: "urgent" }, { origin: "https://evil.example" });
99 expect(res.status).toBe(403);
100 expect(labelled()).toEqual([[], []]);
101 });
102
103 test("preview shows each row's change and writes nothing; a refusal is a muted line", async () => {
104 const res = await post("/fleet", { q, step: "preview", verb: "label", value: "urgent" });
105 expect(res.status).toBe(200);
106 const body = await res.text();
107 expect(body).toContain("data-preview");
108 expect(body).toContain("Apply to 2 rows");
109 expect(body).toContain("will change");
110 expect(body).toContain("none → urgent");
111 expect(labelled()).toEqual([[], []]);
112 const none = await (await post("/fleet", { q, step: "preview", verb: "label", value: "nope" })).text();
113 expect(none).toContain("no label named nope in alice/engine");
114 expect(none).toContain('class="muted"');
115 const bad = await (await post("/fleet", { q, step: "preview", verb: "archive", value: "true" })).text();
116 expect(bad).toContain("data-refused");
117 });
118
119 test("apply writes through the operation and lands on a receipt; Undo puts it back, once", async () => {
120 const applied = await apply({ q, verb: "label", value: "urgent" });
121 expect(applied.status).toBe(303);
122 const at = applied.headers.get("location")!;
123 expect(at).toMatch(/^\/fleet\/ops\//);
124 expect(labelled()).toEqual([["urgent"], ["urgent"]]);
125 const receipt = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text();
126 expect(receipt).toContain("data-receipt");
127 expect(receipt).toContain(`action="${at}/undo"`);
128
129 const undone = await post(`${at}/undo`, {});
130 expect(undone.status).toBe(303);
131 expect(labelled()).toEqual([[], []]);
132 const after = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text();
133 expect(after).not.toContain("/undo\"");
134 await post(`${at}/undo`, {});
135 expect(labelled()).toEqual([[], []]);
136 });
137
138 test("a cross-origin undo is refused", async () => {
139 const applied = await apply({ q, verb: "label", value: "urgent" });
140 const at = applied.headers.get("location")!;
141 const res = await post(`${at}/undo`, {}, { origin: "https://evil.example" });
142 expect(res.status).toBe(403);
143 expect(labelled()).toEqual([["urgent"], ["urgent"]]);
144 });
145
146 test("an agent's POST is not a write", async () => {
147 const made = await createAgent(app, getUserByName(app.db, "root")!, { username: "botty", brief: "Be brief." });
148 if (!made.ok) throw new Error(String(made.error));
149 const agent = `forge_session=${createSession(app.db, made.value.user.id).token}`;
150 const res = await post("/fleet", { q, step: "apply", verb: "label", value: "urgent", digest: "x" }, { cookie: agent });
151 expect(res.status).toBe(404);
152 expect(labelled()).toEqual([["urgent"], ["urgent"]]);
153 const page = await (await fetch(`${servers.url}/fleet?${new URLSearchParams({ q })}`, { headers: { cookie: agent } })).text();
154 expect(page).not.toContain("data-act");
155 });
156
157 test("a row alice does not administer is refused in apply and left alone", async () => {
158 const bob = await person("bob");
159 const repo = await createRepo(app, bob, { name: "other", visibility: "public" });
160 if (!repo.ok) throw new Error(String(repo.error));
161 const issue = openIssue(app, bob, repo.value, { title: "Theirs" });
162 if (!issue.ok) throw new Error(String(issue.error));
163 createLabel(app, bob, repo.value, "urgent", "ff0000");
164 const alice = getUserByName(app.db, "alice")!;
165 const engine = getRepo(app.db, "alice", "engine")!;
166 const mineToo = openIssue(app, alice, engine, { title: "Mine" });
167 if (!mineToo.ok) throw new Error(String(mineToo.error));
168 const mine = "issue repo:alice/engine,bob/other";
169 const applied = await apply({ q: mine, verb: "label", value: "urgent" });
170 const at = applied.headers.get("location")!;
171 const receipt = await (await fetch(`${servers.url}${at}`, { headers: { cookie } })).text();
172 expect(receipt).toContain('data-outcome="refused"');
173 expect(labelsFor(app.db, issue.value.id)).toEqual([]);
174 expect(labelsFor(app.db, mineToo.value.id).map((l) => l.name)).toEqual(["urgent"]);
175 });
176
177 test("apply is bound to what was previewed: rows that moved are refused, nothing written", async () => {
178 const alice = getUserByName(app.db, "alice")!;
179 const repo = await createRepo(app, alice, { name: "fresh", visibility: "public" });
180 if (!repo.ok) throw new Error(String(repo.error));
181 createLabel(app, alice, repo.value, "urgent", "ff0000");
182 const first = openIssue(app, alice, repo.value, { title: "One" });
183 if (!first.ok) throw new Error(String(first.error));
184 const fq = "issue repo:alice/fresh";
185 const body = { q: fq, verb: "label", value: "urgent" };
186 const preview = await (await post("/fleet", { ...body, step: "preview" })).text();
187 const digest = /name="digest" value="([0-9a-f]+)"/.exec(preview)?.[1];
188 expect(digest).toBeDefined();
189 const second = openIssue(app, alice, repo.value, { title: "Two" });
190 if (!second.ok) throw new Error(String(second.error));
191 const stale = await post("/fleet", { ...body, step: "apply", digest: digest! });
192 expect(stale.status).toBe(200);
193 expect(await stale.text()).toContain("rows changed since the preview");
194 expect(labelsFor(app.db, first.value.id)).toEqual([]);
195 expect(labelsFor(app.db, second.value.id)).toEqual([]);
196 const missing = await post("/fleet", { ...body, step: "apply" });
197 expect(missing.status).toBe(200);
198 expect(labelsFor(app.db, first.value.id)).toEqual([]);
199 const fresh = await (await post("/fleet", { ...body, step: "preview" })).text();
200 const again = /name="digest" value="([0-9a-f]+)"/.exec(fresh)![1]!;
201 const ok = await post("/fleet", { ...body, step: "apply", digest: again });
202 expect(ok.status).toBe(303);
203 expect(labelsFor(app.db, second.value.id).map((l) => l.name)).toEqual(["urgent"]);
204 });
205 });
test/security-floor-routes.test.ts +1
@@ -55,6 +55,7 @@ const UNSEEDED: Record<string, string> = {
5555 "/:owner/-/teams/:team": ORG,
5656 "/talk/:id": TALK,
5757 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
58 "/fleet/ops/:id": FLEET,
5859 "/:owner/:name/bets/:id": BET,
5960 "/:owner/:name/builds/:label/cart": CART,
6061 "/:owner/:name/builds/:label/cart.png": CART,
test/tui-fleet.test.ts +39 −1
@@ -9,8 +9,10 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
99 import { mkdtemp, rm } from "node:fs/promises";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createLabel, createRepo } from "../src/actions/repos.ts";
1314 import { createApp, type App as Forge } from "../src/app.ts";
15 import { labelsFor } from "../src/core/issues.ts";
1416 import { createUser, getUserByName, type User } from "../src/core/users.ts";
1517 import { createForgeApp } from "../src/tui/forge.ts";
1618 import { fleetLines, nextSort } from "../src/tui/screens/fleet.ts";
@@ -137,3 +139,39 @@ test("fleetLines draws a header and one line a row, in the shared columns", () =
137139 expect(nextSort("issue", undefined)).toEqual({ key: "repo", descending: false });
138140 expect(nextSort("issue", { key: "assignee", descending: true })).toEqual({ key: "repo", descending: false });
139141 });
142
143 test("v previews a verb, Apply writes it, u undoes it; a guest is offered none of it", async () => {
144 const alice = getUserByName(forge.db, "alice")!;
145 const repo = await createRepo(forge, alice, { name: "board", visibility: "public" });
146 if (!repo.ok) throw new Error(String(repo.error));
147 createLabel(forge, alice, repo.value, "urgent", "ff0000");
148 const issue = openIssue(forge, alice, repo.value, { title: "Fix it" });
149 if (!issue.ok) throw new Error(String(issue.error));
150 const names = () => labelsFor(forge.db, issue.value.id).map((l) => l.name);
151
152 const t = boot(alice);
153 await t.select("issue repo:alice/board");
154 await t.key("v");
155 for (const ch of "label urgent") await t.key(ch);
156 await t.key("enter");
157 expect(t.text()).toContain("Preview: label");
158 expect(t.text()).toContain("none -> urgent");
159 expect(names()).toEqual([]);
160 await t.key("enter");
161 await Bun.sleep(20);
162 await t.key("j");
163 expect(names()).toEqual(["urgent"]);
164 expect(t.text()).toContain("Applied");
165 await t.key("u");
166 await t.key("enter");
167 await Bun.sleep(20);
168 await t.key("j");
169 expect(names()).toEqual([]);
170 expect(t.text()).toContain("Undone.");
171
172 const guest = boot(undefined);
173 await guest.select("issue repo:alice/board");
174 await guest.key("v");
175 expect(guest.text()).not.toContain("Verb");
176 expect(names()).toEqual([]);
177 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs