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

A fleet operation archives or wakes many repositories at once, with the fleet's preview, receipt and 24-hour undo #2517

Merged opened this

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

Merged

scout/forge:scout/2511-a-fleet-operation-archives-or-wakes-many → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2511-a-fleet-operation-archives-or-wakes-many was merged into main as 1ef58e9.

Review

Sign in to review this change.
6 files changed +232 −4
README.md +1
@@ -904,6 +904,7 @@ stands, taking updates, and the crew's part when both sides moved.
904904
905905 `docs/FLEET.md` is one change across many repositories, runs or issues: a
906906 query, a dry-run plan of every row before → after, a receipt, and undo.
907 `archive` (true or false) archives or wakes many repositories; its undo is kept a day.

Add archive to the actions table in docs/FLEET.md too; this one line is the only mention.

907908
908909 `docs/MCP.md` is the forge as a connector, and `docs/STANDARDS.md` is what
909910 it answers by default: `.well-known`, feeds, cards, the manifest.
docs/FLEET.md +5 −1
@@ -48,6 +48,7 @@ table (#446) grows it.
4848 | repo | `slots` | 1–8 | yes |
4949 | repo | `ceiling` | dollars, 0–10000 | yes |
5050 | repo | `direction` | text, ≤ 2000 | yes |
51 | repo | `archive` | `true` archives, `false` wakes | yes, kept a day |
5152 | run | `cancel` | — | no |
5253 | run | `requeue` | — (a cancelled or failed agent run) | no |
5354 | issue | `close` | — | yes |
@@ -78,7 +79,10 @@ operations; `GET /api/v1/fleet/ops/:id` is one receipt.
7879 Undo is a new operation that writes each `ok` row's value from before,
7980 only where the row still holds what the operation left; a row someone
8081 changed since is refused as `changed since: it is … now`. It writes the
81 rows backwards, so what was done last is undone first.
82 rows backwards, so what was done last is undone first. The undo of an
83 `archive` expires 24 hours after the operation (`expired`); the other verbs'
84 undo has no time limit. The same holds through the API and MCP's `limb`
85 `undo`.
8286
8387 `file` says where an issue stands in the plan: `later` puts it behind the
8488 Later gate and under the Later umbrella, out from under any pillar; a
src/actions/fleet.ts +9 −2
@@ -566,6 +566,13 @@ export function isAsksOperation(op: Operation): boolean {
566566 );
567567 }
568568
569 /** Verbs whose undo is kept for a day only, besides the asks' bulk actions. */
570 const DAY_VERBS: readonly string[] = ["archive"];
571
572 /** Whether an operation's undo lapses after `UNDO_WINDOW_MS`. */
573 const keepsADay = (op: Operation): boolean =>
574 isAsksOperation(op) || DAY_VERBS.includes(op.verb);
575
569576 /**
570577 * Undo: every row this operation changed goes back to its value from
571578 * before, if it still holds the value this operation left. Once per
@@ -593,8 +600,8 @@ export async function undoFleetOp(
593600 (verb.personal || !parseQuery(op.query).ok)
594601 )
595602 return fail("not_found", "no such operation here");
596 // The asks' day is the same on every door: the card, the API, MCP.
597 if (isAsksOperation(op) && Date.now() - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
603 // The day is the same on every door: the card, the API, MCP.
604 if (keepsADay(op) && Date.now() - Date.parse(op.createdAt) > UNDO_WINDOW_MS)
598605 return fail("expired", "an undo is kept for a day, and this one's day is over");
599606 if (!claimUndo(app.db, op.id))
600607 return fail("already_undone", "this operation was undone already");
src/core/fleet_ops.ts +1 −1
@@ -10,7 +10,7 @@ import { transact } from "../kernel/db.ts";
1010 import { id } from "../kernel/id.ts";
1111
1212 /** A row's value, as it is shown before → after: plain JSON. */
13 export type FleetValue = string | number | null | readonly (string | number)[];
13 export type FleetValue = string | number | boolean | null | readonly (string | number)[];
1414
1515 export type RowOutcome = "ok" | "failed" | "refused" | "unchanged";
1616
src/fleet/verbs.ts +14
@@ -307,6 +307,20 @@ export const VERBS: Record<string, Verb> = {
307307 : err("the direction is text of at most 2000 characters"),
308308 ),
309309
310 archive: {
311 kind: "repo",
312 reversible: true,
313 parse: (_app, value) =>
314 typeof value === "boolean" ? ok(value) : err("archive is true or false"),
315 read: (t) => t.repo.archivedAt !== null,
316 next: replace,
317 refuse: () => undefined,
318 write: async (app, actor, t, after) =>
319 typeof after === "boolean"
320 ? done(await updateRepoSettings(app, actor, t.repo, { archived: after }))
321 : "not a value for this setting",
322 },
323
310324 cancel: {
311325 kind: "run",
312326 reversible: false,
test/fleet-archive.test.ts added +202
@@ -0,0 +1,202 @@
1 /**
2 * The `archive` fleet verb: many repositories archived in one operation,
3 * previewed by default, and woken again by the operation's own undo, inside
4 * the day the undo is kept.
5 */
6 import { afterAll, afterEach, beforeAll, describe, expect, setSystemTime, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import {
12 UNDO_WINDOW_MS,
13 applyFleetOp,
14 previewFleetOp,
15 undoFleetOp,
16 } from "../src/actions/fleet.ts";
17 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
18 import { getRepoById, type Repo } from "../src/core/repos.ts";
19 import { createToken } from "../src/core/tokens.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { promote } from "./helpers/members.ts";
23
24 describe("fleet archive", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let token: string;
29 let alice: User;
30 let bob: User;
31 let repos: Repo[];
32 let bobs: Repo;
33
34 const person = async (username: string) => {
35 const made = await createUser(app.db, {
36 origin: "seed",
37 trusted: true,
38 username,
39 allowReserved: true,
40 adminIfFirst: username === "root",
41 email: `${username}@forge.local`,
42 password: "a-good-passphrase",
43 });
44 if (!made.ok) throw new Error(made.error);
45 promote(app.db, made.value);
46 return made.value;
47 };
48 const repoOf = async (owner: User, name: string) => {
49 const made = await createRepo(app, owner, { name, visibility: "public" });
50 if (!made.ok) throw new Error(String(made.error));
51 return made.value;
52 };
53 const archived = (repo: Repo) => getRepoById(app.db, repo.id)!.archivedAt !== null;
54 const wake = async (repo: Repo) => {
55 if (archived(repo)) await updateRepoSettings(app, alice, repo, { archived: false });
56 };
57
58 beforeAll(async () => {
59 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-archive-"));
60 app = await createApp({ dataDir });
61 await person("root");
62 alice = await person("alice");
63 bob = await person("bob");
64 repos = [];
65 for (const name of ["one", "two", "three", "four"]) repos.push(await repoOf(alice, name));
66 bobs = await repoOf(bob, "garden");
67 servers = await serve(app);
68 token = createToken(app.db, alice.id, "admin", "admin").token;
69 }, 30_000);
70
71 afterEach(async () => {
72 setSystemTime();
73 for (const repo of repos) await wake(repo);
74 });
75
76 afterAll(async () => {
77 servers.stop();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 const Q = "repo owner:alice";
83 const action = { verb: "archive", value: true };
84
85 test("a preview changes nothing", () => {
86 const plan = previewFleetOp(app, alice, Q, action);
87 expect(plan.ok).toBe(true);
88 if (plan.ok) expect(plan.value.counts.change).toBe(4);
89 expect(repos.some(archived)).toBe(false);
90 });
91
92 test("archive three, undo, all three awake", async () => {
93 const ids = repos.slice(0, 3).map((r) => r.id);
94 const applied = await applyFleetOp(app, alice, "repo owner:alice", action);
95 if (!applied.ok) throw new Error(applied.error.message);
96 const mine = applied.value.rows.filter((r) => ids.includes(r.repoId));
97 expect(mine.every((r) => r.outcome === "ok")).toBe(true);
98 expect(repos.every(archived)).toBe(true);
99 const undone = await undoFleetOp(app, alice, applied.value.id);
100 expect(undone.ok).toBe(true);
101 expect(repos.some(archived)).toBe(false);
102 });
103
104 test("one already archived is unchanged and stays archived after undo", async () => {
105 await updateRepoSettings(app, alice, repos[0]!, { archived: true });
106 const applied = await applyFleetOp(app, alice, "repo owner:alice", action);
107 if (!applied.ok) throw new Error(applied.error.message);
108 const first = applied.value.rows.find((r) => r.repoId === repos[0]!.id)!;
109 expect(first.outcome).toBe("unchanged");
110 const undone = await undoFleetOp(app, alice, applied.value.id);
111 expect(undone.ok).toBe(true);
112 expect(archived(repos[0]!)).toBe(true);
113 expect(repos.slice(1).some(archived)).toBe(false);
114 });
115
116 test("a row the actor does not administer is refused and stays awake", async () => {
117 const applied = await applyFleetOp(app, alice, "repo", action);
118 if (!applied.ok) throw new Error(applied.error.message);
119 const row = applied.value.rows.find((r) => r.repoId === bobs.id);
120 expect(row?.outcome).toBe("refused");
121 expect(archived(bobs)).toBe(false);
122 for (const repo of repos) await wake(repo);
123 const own = await applyFleetOp(app, bob, "repo owner:alice", action);
124 if (!own.ok) throw new Error(own.error.message);
125 expect(own.value.rows.every((r) => r.outcome === "refused")).toBe(true);
126 expect(repos.some(archived)).toBe(false);
127 });
128
129 test("a value that is not a boolean is refused", () => {
130 for (const value of ["yes", 1, null]) {
131 const plan = previewFleetOp(app, alice, Q, { verb: "archive", value });
132 expect(plan.ok).toBe(false);
133 }
134 });
135
136 test("an undo after 24 hours is refused", async () => {
137 const applied = await applyFleetOp(app, alice, Q, action);
138 if (!applied.ok) throw new Error(applied.error.message);
139 setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000));
140 const undone = await undoFleetOp(app, alice, applied.value.id);
141 expect(undone.ok).toBe(false);
142 if (!undone.ok) expect(undone.error.code).toBe("expired");
143 expect(repos.every(archived)).toBe(true);
144 });
145
146 const api = (path: string, body: unknown) =>
147 fetch(`${servers.url}${path}`, {
148 method: "POST",
149 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
150 body: JSON.stringify(body),
151 });
152
153 test("the API door archives, and its undo is refused after 24 hours", async () => {
154 const dry = await api("/api/v1/fleet/ops", { query: Q, action });
155 expect(dry.status).toBe(200);
156 expect(repos.some(archived)).toBe(false);
157 const done = await api("/api/v1/fleet/ops", { query: Q, action, dryRun: false });
158 expect(done.status).toBe(201);
159 const op = (await done.json()) as { id: string };
160 expect(repos.every(archived)).toBe(true);
161 setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000));
162 const late = await api(`/api/v1/fleet/ops/${op.id}/undo`, {});
163 expect(late.status).toBe(409);
164 expect(JSON.stringify(await late.json())).toContain("expired");
165 expect(repos.every(archived)).toBe(true);
166 });
167
168 test("the MCP door undoes an archive inside the day and not after it", async () => {
169 const mcp = async (operation: string) => {
170 const res = await fetch(`${servers.url}/mcp`, {
171 method: "POST",
172 headers: {
173 "content-type": "application/json",
174 accept: "application/json, text/event-stream",
175 authorization: `Bearer ${token}`,
176 },
177 body: JSON.stringify({
178 jsonrpc: "2.0",
179 id: 1,
180 method: "tools/call",
181 params: {
182 name: "limb",
183 arguments: { app: "alice/one", action: "undo", operation, dryRun: false },
184 },
185 }),
186 });
187 const body = (await res.json()) as any;
188 return { error: body.result.isError === true, text: body.result.content[0].text as string };
189 };
190 const first = await applyFleetOp(app, alice, Q, action);
191 if (!first.ok) throw new Error(first.error.message);
192 expect((await mcp(first.value.id)).error).toBe(false);
193 expect(repos.some(archived)).toBe(false);
194 const second = await applyFleetOp(app, alice, Q, action);
195 if (!second.ok) throw new Error(second.error.message);
196 setSystemTime(new Date(Date.now() + UNDO_WINDOW_MS + 60_000));
197 const late = await mcp(second.value.id);
198 expect(late.error).toBe(true);
199 expect(late.text).toContain("expired");
200 expect(repos.every(archived)).toBe(true);
201 });
202 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs