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

A forge's host can see which hosted forges are in use, and archive one: expor… #12266

Merged opened this in answer to

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

Merged

mason/forge:mason/12262-a-forge-s-host-can-see-which-hosted-forg → main 15 commits fork

  • Merged. mason/12262-a-forge-s-host-can-see-which-hosted-forg was merged into main as 1a6be99.

Review

Sign in to review this change.
26 files changed +1140 −40
docs/MCP.md +1 −1
@@ -117,7 +117,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
117117 | `forge` | — | How is my forge? What needs you, recommended; `apply` takes them | 199 |
118118 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
119119 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 282 |
120 | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 537 |
120 | `forges` | `refused` | Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant | 629 |
121121 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
122122 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 440 |
123123 | `forks` | `chain` | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 480 |
docs/design/audit.md +1
@@ -95,6 +95,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
9595
9696 - `/forges` — `pages/forges.ts` · the forges this one sent, as a client list · `/family` · colour 1.
9797 - `/forges/refused` — `pages/forges-refused.ts` · the last 20 first asks the forge refused, with its words; an admin's only · `/system` · none seen; reuses the list and muted text.
98 - `/forges/archive` — `pages/forges.ts` · what archiving does for the forges named, and the one button · `/forges` · colour 1; reuses the list and button.
9899 - `/forges/new` — `pages/new-forge.ts` · send a new forge · none · colour 1.
99100 - `/family` — `pages/family.ts` · parent, this forge and children as a tree · `/forges` (the same children) · colour 1.
100101 - `/claim` — `pages/claim.ts` · claim a forge that was sent to me · none · colour 1.
docs/features/forge-archive.md added +32
@@ -0,0 +1,32 @@
1 # Which hosted forges are in use, and archiving one
2
3 For the host (a site admin). `/forges` answers "is this forge in use?" and lets you archive the ones that are not.
4
5 ## What each row says
6
7 - **A person last did something here**: a sign-in, a token used (a push), an account made by the claim, an issue, a comment, a run a person started. Never a heartbeat, telemetry, the engine's own ticks or what the crew did by itself. The host reads it from the forge's own tables, so a sleeping forge and one on older code answer alike (`src/spawn/person_activity.ts`, asked through `src/spawn/child_db.ts`).
8 - **Claimed by** who, and when.
9 - **What it costs to keep**: its data directory's size, its memory while awake, the credit it has spent.
10 - Sort with the links above the list: needs you first, quietest first, most recent activity, costliest to keep (`?sort=quiet|recent|cost`).
11
12 ## Archive and Restore
13
14 Tick one or several forges and press "Archive selected", or press "Archive" on a row; the next page says what will happen. Then, for each forge, in order:
15
16 0. the destination takes and returns a small test file, so a forge is not stopped for a copy that cannot be kept; one archive or restore of a name runs at a time (a second ask is refused);
17 1. the forge is stopped;
18 2. its data directory and ledger are written as one `tar.gz` (at most 256 MiB: it is held in memory to be verified; a larger forge is refused with a sentence, asleep and untouched) and uploaded to the host's backup destination (the one on the Backups page, or `FORGE_SNAPSHOT_DIR`), under `forge-archives/<name>/`;
19 3. the copy is read back and compared byte for byte (sha-256). **If it does not match, or there is no destination, nothing is removed**: the forge stays, asleep (it wakes on its next request), and the local copy is kept under `<data>/archive/files/`;
20 4. only then the forge is removed, the local copy is deleted, and one line is left on `/forges` under **Archived** naming where the copy is.
21
22 **Restore** on that line brings the forge back under the same name, from the copy (checked against its recorded hash first). It refuses if the name has been taken since, and refuses any copy that holds anything but plain files and directories under the forge's own names (the backup store is less trusted than the host).
23
24 The MCP `forges` tool does the same with `archive: <name or names>` and `restore: <name>`, planned unless `dryRun: false`. The MCP archives at most five per call. In the terminal, `a` on the forges screen archives one (the dialog names where the copy goes) and `r` restores one; the terminal has no sort, the page and the MCP list do.
25
26 ## Proposals
27
28 A forge that nobody claimed and no person has touched for 30 days is proposed for archive in the needs-you line on the home, with "archive" as the recommended choice. **Nothing is archived by itself.**
29
30 A claimed forge belongs to its claimant. After 30 untouched days the host mails them (to the address on their account there; a notice counts only when the mail was sent). That address is read from the forge's own data, which the forge writes, so it is not trusted as an identity: at most one notice is ever sent per forge, at most five per pass, and the words are this host's own, never the forge's. The forge is proposed only a week after that, and not at all if a person came back since. A host with no mail configured never proposes a claimed forge.
31
32 Events: `forge.archived`, `forge.restored`, `forge.archive_noticed`.
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt, running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" };
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt (a person or the crew last did something there, never a heartbeat), claimedBy, claimedAt, keepBytes, archive (why it is proposed for archive, or null), running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true, role: "admin" };
src/core/event_audience.ts +3
@@ -110,6 +110,9 @@ const READER_KINDS: ReadonlySet<string> = new Set([
110110 "forge.spawn.started",
111111 "forge.spawned",
112112 "forge.removed",
113 "forge.archived",
114 "forge.restored",
115 "forge.archive_noticed",
113116 "forge.stopped",
114117 // A forge's own one line to its people: it runs in a sandbox now (`spawn/sandboxed.ts`). It names
115118 // nothing.
src/http/mcp/tools/forges.ts +22 −3
@@ -4,19 +4,21 @@ import { REFUSED_SHOWN, listRefusals, mayReadRefusals } from "../../../core/firs
44 import { publicOrigin } from "../../../core/public_origin.ts";
55 import { scopeAllows } from "../../../core/tokens.ts";
66 import { clientRows } from "../../../spawn/clients.ts";
7 import { archiveForge, archivedForges, restoreForge } from "../../../spawn/archive.ts";
8 import { CHILD_NAME } from "../../../spawn/placement.ts";
79 import { checkMove } from "../../../spawn/move_check.ts";
810 import { moveToCell } from "../../../spawn/move_cell.ts";
911 import { good, bad, type Tool } from "../shared.ts";
1012
1113 export const tool: Tool = {
1214 name: "forges",
13 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity, reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand; `{ forge, sandbox: true }` reads what moving a forge out of this forge's own process into a sandbox would meet (hooks and command-running config keys by name, size) and with `dryRun: false` moves it, proven and put back on any failure (`acknowledge: true` when it was held for what its repositories hold); each forge carries `placement` and `where`; gift codes: `codes` true lists, `code` {plan, uses?, note?, expires?} makes (shown once), `revoke` id; planned unless `dryRun` is false; `kind` refused lists the last 20 refused first asks",
15 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, stage (sent/opened/signed in/last tell; resend true when unopened 3 days: claim_link), last activity (a person or the crew, never a heartbeat), who claimed it and when, what it costs to keep (keepBytes, use, credit), archive (why it is proposed for archive), reachable, size, use; `{ forge, plan?, dryRun? }` reads a forge's plan (small|medium|large, status, source, changes) or, with `plan`, grants it by hand; `{ forge, sandbox: true }` reads what moving a forge out of this forge's own process into a sandbox would meet (hooks and command-running config keys by name, size) and with `dryRun: false` moves it, proven and put back on any failure (`acknowledge: true` when it was held for what its repositories hold); each forge carries `placement` and `where`; gift codes: `codes` true lists, `code` {plan, uses?, note?, expires?} makes (shown once), `revoke` id; planned unless `dryRun` is false; `archive` a name or names stops each, writes its data and ledger as one file to the backup destination, then removes it; `restore` a name brings an archived one back; `kind` refused lists the last 20 refused first asks",
1416 description:
1517 "Forges sent from here (admin; a stage says when to resend), or one forge's plan to read or grant.",
16 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["refused"] }, forge: { type: "string" }, plan: { type: "string" }, sandbox: { type: "boolean" }, acknowledge: { type: "boolean" }, codes: { type: "boolean" }, code: { type: "object" }, revoke: { type: "string" }, dryRun: { type: "boolean" } } },
18 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["refused"] }, forge: { type: "string" }, plan: { type: "string" }, codes: { type: "boolean" }, code: { type: "object" }, revoke: { type: "string" }, archive: { type: ["string", "array"], items: { type: "string" } }, restore: { type: "string" }, sandbox: { type: "boolean" }, acknowledge: { type: "boolean" }, dryRun: { type: "boolean" } } },
1719 writes: true,
1820 dryByDefault: true,
19 readsWithout: ["forge", "plan", "code", "revoke"],
21 readsWithout: ["forge", "plan", "code", "revoke", "archive", "restore"],
2022 async run(ctx, user, args) {
2123 if (!user.isAdmin)
2224 return bad("forbidden", "only an admin of this forge sees the forges it sent");
@@ -47,6 +49,23 @@ export const tool: Tool = {
4749 const made = makeCode(ctx.app, user, input, publicOrigin(ctx.app.config.url, ctx.url.origin));
4850 return made.ok ? good({ code: made.value.code, link: made.value.link, says: "Shown once: the plain code is not kept." }) : no(made.error);
4951 }
52 if (args["archive"] !== undefined || args["restore"] !== undefined) {
53 const live = args["dryRun"] === false;
54 if (live && !scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "archiving needs an admin token");
55 if (args["restore"] !== undefined) {
56 const name = String(args["restore"]);
57 if (!CHILD_NAME.test(name)) return bad("invalid", "restore names an archived forge");
58 if (!live) return good({ says: `This would restore ${name} from its archive under the same name; nothing changed. Send dryRun: false to do it.`, archived: archivedForges(ctx.app) });
59 const done = await restoreForge(ctx.app, name, { actorId: user.id });
60 return done.ok ? good({ says: done.says }) : bad("invalid", done.says);
61 }
62 const names = (Array.isArray(args["archive"]) ? args["archive"] : [args["archive"]]).map(String);
63 if (names.length === 0 || names.length > 5 || !names.every((n) => CHILD_NAME.test(n))) return bad("invalid", "archive names one to five forges");
64 if (!live) return good({ says: `This would stop ${names.join(", ")}, write each one's data and ledger as one file to the backup destination, then remove it; nothing changed. Send dryRun: false to do it.` });
65 const says: string[] = [];
66 for (const name of names) says.push((await archiveForge(ctx.app, name, { actorId: user.id })).says);
67 return good({ says: says.join(" · ") });
68 }
5069 // Before a forge in this forge's own process is moved into a sandbox: what the move would meet,
5170 // names only.
5271 if (args["sandbox"] === true) {
src/http/route_marks.ts +2
@@ -219,6 +219,8 @@ export const ROUTES: Readonly<Record<string, Mark>> = {
219219 "/forges/:name/reach/revoke": "admin",
220220 "/forges/:name/own-code": "admin",
221221 "/forges/:name/remove": "admin",
222 "/forges/:name/restore": "admin",
223 "/forges/archive": "admin",
222224 "/forges/:name/ready": "open",
223225 "/forges/new": "open",
224226 "/forges/start": "open",
src/http/server.ts +4 −1
@@ -296,7 +296,7 @@ import { forgeName } from "../web/pages/forge-name.ts";
296296 import { claimFragmentPage, claimOptions, claimSubmit, welcomePage, welcomeSubmit } from "../web/pages/claim.ts";
297297 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
298298 import { familyPage } from "../web/pages/family.ts";
299 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
299 import { forgeLinkRevoke, forgeLinkSubmit, forgeLinkUndo, forgeArchiveConfirm, forgeArchiveSubmit, forgeRestoreSubmit, forgeRemoveConfirm, forgeRemoveSubmit, forgeStopSubmit,
300300 forgeOwnCodeSubmit, forgeSandboxSubmit, forgesPage } from "../web/pages/forges.ts";
301301 import { forgesRefusedPage } from "../web/pages/forges-refused.ts";
302302 import { forgeReachAllow, forgeReachLater, forgeReachRevoke } from "../web/pages/hosted-forges.ts";
@@ -899,6 +899,9 @@ export function buildRouter(): Router<Handler> {
899899 // "+ New forge": the spawn door as a page, any signed-in person (spawn/door.ts).
900900 router.add("GET", "/forges", forgesPage);
901901 router.add("GET", "/forges/refused", forgesRefusedPage);
902 router.add("GET", "/forges/archive", forgeArchiveConfirm);
903 router.add("POST", "/forges/archive", forgeArchiveSubmit);
904 router.add("POST", "/forges/:name/restore", forgeRestoreSubmit);
902905 router.add("GET", "/family", familyPage);
903906 router.add("GET", "/forges/name/:name", forgeName);
904907 router.add("POST", "/forges/:name/claim-link", forgeLinkSubmit);
src/spawn/app_follow.ts +9 −2
@@ -20,6 +20,7 @@ import { getFollow, listFollowing } from "../core/upstream_follow.ts";
2020 import { resolveOid } from "../git/merge.ts";
2121 import { repoDir } from "../git/repos.ts";
2222 import { applyAppLinks, entriesOf, reasonOf, reconcileAppLinks } from "./app_links.ts";
23 import { noticeOwners } from "./archive.ts";
2324 import { refreshServed } from "./clients.ts";
2425 import { hostedChildOpen, openChildren } from "./host.ts";
2526
@@ -81,8 +82,14 @@ export async function followTick(app: App, boot = false): Promise<void> {
8182 }
8283 }
8384 // The list /forges serves is read here, off any request: with a question to each child at a start, never after.
84 await refreshServed(app, { probe: boot }).catch((error) =>
85 app.log.warn("the forges list was not read", { reason: reasonOf(error) }),
85 const rows = await refreshServed(app, { probe: boot }).catch((error) => {
86 app.log.warn("the forges list was not read", { reason: reasonOf(error) });
87 return [];
88 });
89 // A claimed forge nobody has touched for 30 days: its owner is told before it can be proposed
90 // (#12262).
91 await noticeOwners(app, rows, Date.now()).catch((error) =>
92 app.log.warn("an archive notice was not sent", { reason: reasonOf(error) }),
8693 );
8794 }
8895
src/spawn/archive.ts added +295
@@ -0,0 +1,295 @@
1 /**
2 * Archive a hosted forge (#12262): stop it, write its data directory and
3 * ledger as ONE file to the host's backup destination (kept here until the
4 * copy is read back and matches), remove it, and leave one record under
5 * "Archived" saying where the copy is. Restore brings it back under the same
6 * name. Nothing is removed before the copy is verified: a failure leaves the
7 * forge stopped and its data where it was.
8 *
9 * Kept under `<data>/archive/`: `<name>.json` the record, `files/` the local
10 * copy until verified, `notices.json` when each claimant was told.
11 */
12
13 import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
14 import { rm } from "node:fs/promises";
15 import { join } from "node:path";
16 import type { App } from "../app.ts";
17 import { mailerOf } from "../actions/verify.ts";
18 import { childDirOf, listChildren } from "../actions/spawn.ts";
19 import { sha256 } from "../kernel/hash.ts";
20 import { resolveDestination, type Destination } from "../snapshot/destination.ts";
21 import { childPerson } from "./child_db.ts";
22 import { dropServed, type ClientRow } from "./clients.ts";
23 import { needsNotice } from "./archive_proposal.ts";
24 import { removeForge, type Managed } from "./manage.ts";
25 import { sleepChild } from "./host.ts";
26 import { CHILD_NAME } from "./placement.ts";
27
28 export interface ArchiveRecord {
29 readonly name: string;
30 readonly bodyId: string;
31 readonly archivedAt: string;
32 /** The store it was written to (`host/bucket` or a directory), never a key. */
33 readonly where: string;
34 readonly key: string;
35 readonly bytes: number;
36 readonly sha256: string;
37 readonly for: string | null;
38 readonly apps: readonly string[];
39 }
40
41 /** Tests swap where the copy goes. */
42 export interface ArchiveOptions {
43 readonly destination?: Destination | undefined;
44 readonly now?: number;
45 readonly actorId?: string | undefined;
46 /** Tests lower the cap. */
47 readonly maxBytes?: number;
48 }
49
50 /** The largest archive written or restored: it is read into memory whole (twice, to verify it). */
51 export const ARCHIVE_MAX_BYTES = 256 * 1_048_576;
52
53 /** Forges being archived or restored now, by name: a second ask for one is refused, not raced. */
54 const busy = new Set<string>();
55
56 const root = (app: App): string => join(app.config.dataDir, "archive");
57 const recordPath = (app: App, name: string): string => join(root(app), `${name}.json`);
58 const BODY_ID = /^[A-Za-z0-9_-]{1,64}$/;
59
60 /** The archived forges, newest first: one line each on /forges. */
61 export function archivedForges(app: App): ArchiveRecord[] {
62 let files: string[];
63 try {
64 files = readdirSync(root(app));
65 } catch {
66 return [];
67 }
68 const out: ArchiveRecord[] = [];
69 for (const f of files.filter((f) => f.endsWith(".json") && f !== "notices.json")) {
70 try {
71 const r = JSON.parse(readFileSync(join(root(app), f), "utf8")) as ArchiveRecord;
72 if (CHILD_NAME.test(r.name) && BODY_ID.test(r.bodyId) && typeof r.key === "string") out.push(r);
73 } catch {}
74 }
75 return out.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
76 }
77
78 async function tar(args: readonly string[]): Promise<{ ok: boolean; out: string; err: string }> {
79 const p = Bun.spawn(["tar", ...args], { stdout: "pipe", stderr: "pipe" });
80 const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
81 return { ok: (await p.exited) === 0, out, err: err.trim().slice(0, 200) };
82 }
83
84 /** Where an archive would be written, in words: the store, or that there is none. */
85 export function destinationWords(app: App): string {
86 const d = resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus });
87 return d === undefined ? "a backup destination (none is set)" : d.store.where;
88 }
89
90 export async function archiveForge(app: App, name: string, options: ArchiveOptions = {}): Promise<Managed> {
91 if (!CHILD_NAME.test(name)) return { ok: false, says: `no forge named ${name}` };
92 if (busy.has(name)) return { ok: false, says: `${name} is being archived or restored already` };
93 busy.add(name);
94 try {
95 return await archiveOne(app, name, options);
96 } finally {
97 busy.delete(name);
98 }
99 }
100
101 const STOPPED_SAFE = "it is stopped, wakes on its next request, and its data is untouched";
102
103 async function archiveOne(app: App, name: string, options: ArchiveOptions): Promise<Managed> {
104 const child = (await listChildren(app)).find((c) => c.name === name);
105 if (child === undefined) return { ok: false, says: `no forge named ${name}` };
106 if (!child.done) return { ok: false, says: `${name} is still being made` };
107 if (!BODY_ID.test(child.bodyId)) return { ok: false, says: `${name} has no data directory to archive` };
108 const destination = options.destination ?? resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus });
109 if (destination === undefined)
110 return { ok: false, says: `${name} was not archived: this forge has no backup destination to write the copy to (set one on the Backups page)` };
111 const now = options.now ?? Date.now();
112 // The destination answers before anything is stopped: a forge is not put to sleep for a copy that
113 // cannot be kept.
114 try {
115 const probe = `forge-archives/.probe-${name}`;
116 await destination.store.put(probe, "ok");
117 const back = await destination.store.get(probe);
118 await destination.store.delete(probe).catch(() => {});
119 if (back === undefined) throw new Error("the probe did not read back");
120 } catch (error) {
121 return { ok: false, says: `${name} was not archived: ${destination.store.where} did not take a test write (${String(error).slice(0, 160)}); ${name} is running as before` };
122 }
123 const stamp = new Date(now).toISOString().replace(/[-:.]/g, "").slice(0, 15);
124 mkdirSync(join(root(app), "files"), { recursive: true });
125 const local = join(root(app), "files", `${name}-${stamp}.tar.gz`);
126 // Stopped first, so nothing writes while it is read.
127 await sleepChild(app, name);
128 const made = await tar(["-czf", local, "-C", join(app.config.dataDir, "spawn"), `${name}.json`, "-C", join(app.config.dataDir, "children"), child.bodyId]);
129 if (!made.ok) {
130 await rm(local, { force: true });
131 return { ok: false, says: `${name} was not archived: the copy could not be written (${made.err}); ${STOPPED_SAFE}` };
132 }
133 const size = statSync(local).size;
134 const cap = options.maxBytes ?? ARCHIVE_MAX_BYTES;
135 if (size > cap) {
136 await rm(local, { force: true });
137 return { ok: false, says: `${name} was not archived: its data is ${mib(size)}, over the ${mib(cap)} one archive can hold; ${STOPPED_SAFE}` };
138 }
139 const body = new Uint8Array(await Bun.file(local).arrayBuffer());
140 const digest = sha256(body);
141 const key = `forge-archives/${name}/${stamp}.tar.gz`;
142 try {
143 await destination.store.put(key, body);
144 const back = await destination.store.get(key);
145 if (back === undefined || back.length !== body.length || sha256(back) !== digest)
146 return { ok: false, says: `${name} was not archived: the copy at ${destination.store.where} did not read back the same; ${STOPPED_SAFE}, and the local copy is kept at ${local}` };
147 } catch (error) {
148 return { ok: false, says: `${name} was not archived: the upload to ${destination.store.where} failed (${String(error).slice(0, 160)}); ${STOPPED_SAFE}, and the local copy is kept at ${local}` };
149 }
150 const record: ArchiveRecord = {
151 name,
152 bodyId: child.bodyId,
153 archivedAt: new Date(now).toISOString(),
154 where: destination.store.where,
155 key,
156 bytes: body.length,
157 sha256: digest,
158 for: child.for,
159 apps: child.apps,
160 };
161 writeFileSync(recordPath(app, name), JSON.stringify(record));
162 await removeForge(app, name);
163 await rm(local, { force: true });
164 forgetNotice(app, name);
165 dropServed(app);
166 app.bus.append({ kind: "forge.archived", ...(options.actorId === undefined ? {} : { actorId: options.actorId }), payload: { name, where: record.where, key, bytes: record.bytes } });
167 return { ok: true, says: `${name} is archived: ${mib(record.bytes)} written to ${record.where} (${key}), then removed here` };
168 }
169
170 const mib = (n: number): string => `${(n / 1_048_576).toFixed(1)} MiB`;
171
172 export async function restoreForge(app: App, name: string, options: ArchiveOptions = {}): Promise<Managed> {
173 if (!CHILD_NAME.test(name)) return { ok: false, says: `no archived forge named ${name}` };
174 if (busy.has(name)) return { ok: false, says: `${name} is being archived or restored already` };
175 busy.add(name);
176 try {
177 return await restoreOne(app, name, options);
178 } finally {
179 busy.delete(name);
180 }
181 }
182
183 async function restoreOne(app: App, name: string, options: ArchiveOptions): Promise<Managed> {
184 const record = archivedForges(app).find((r) => r.name === name);
185 if (record === undefined) return { ok: false, says: `no archived forge named ${name}` };
186 if ((await listChildren(app)).some((c) => c.name === name))
187 return { ok: false, says: `${name} was not restored: a forge of that name exists now` };
188 const dir = childDirOf(app, record.bodyId);
189 if (existsSync(dir)) return { ok: false, says: `${name} was not restored: its data directory is taken` };
190 const destination = options.destination ?? resolveDestination({ db: app.db, dataDir: app.config.dataDir, bus: app.bus });
191 if (destination === undefined) return { ok: false, says: `${name} was not restored: no backup destination to read the copy from` };
192 if (record.bytes > ARCHIVE_MAX_BYTES) return { ok: false, says: `${name} was not restored: its copy is ${mib(record.bytes)}, over the ${mib(ARCHIVE_MAX_BYTES)} one restore reads` };
193 let body: Uint8Array | undefined;
194 try {
195 body = await destination.store.get(record.key);
196 } catch (error) {
197 return { ok: false, says: `${name} was not restored: ${destination.store.where} did not answer (${String(error).slice(0, 160)})` };
198 }
199 if (body === undefined || body.length !== record.bytes || sha256(body) !== record.sha256)
200 return { ok: false, says: `${name} was not restored: the copy at ${destination.store.where} is missing or is not what was archived` };
201 const stage = join(root(app), `restoring-${name}`);
202 await rm(stage, { recursive: true, force: true });
203 mkdirSync(stage, { recursive: true });
204 const file = join(stage, "copy.tar.gz");
205 const extracted = join(stage, "out");
206 mkdirSync(extracted);
207 await Bun.write(file, body);
208 // The copy comes from a store less trusted than this host: names are checked, and so is the kind
209 // of every entry, so no link can send a later entry's write elsewhere.
210 const listed = await tar(["-tzf", file]);
211 const kinds = await tar(["-tvzf", file]);
212 const entries = listed.out.split("\n").filter((l) => l !== "");
213 const types = kinds.out.split("\n").filter((l) => l !== "");
214 const plain = kinds.ok && types.length === entries.length && types.every((l) => l[0] === "-" || l[0] === "d");
215 const clean = listed.ok && plain && entries.length > 0 && entries.every((e) => !e.split("/").includes("..") && !e.startsWith("/") && (e === `${name}.json` || e === `${record.bodyId}/` || e.startsWith(`${record.bodyId}/`)));
216 const out = clean ? await tar(["-xzf", file, "--no-same-owner", "-C", extracted]) : undefined;
217 if (out === undefined || !out.ok || !existsSync(join(extracted, `${name}.json`))) {
218 await rm(stage, { recursive: true, force: true });
219 return { ok: false, says: `${name} was not restored: the copy is not a forge's archive` };
220 }
221 mkdirSync(join(app.config.dataDir, "children"), { recursive: true });
222 mkdirSync(join(app.config.dataDir, "spawn"), { recursive: true });
223 if (existsSync(join(extracted, record.bodyId))) renameSync(join(extracted, record.bodyId), dir);
224 else mkdirSync(dir, { recursive: true });
225 renameSync(join(extracted, `${name}.json`), join(app.config.dataDir, "spawn", `${name}.json`));
226 await rm(stage, { recursive: true, force: true });
227 await rm(recordPath(app, name), { force: true });
228 dropServed(app);
229 app.bus.append({ kind: "forge.restored", ...(options.actorId === undefined ? {} : { actorId: options.actorId }), payload: { name, from: record.where, key: record.key } });
230 return { ok: true, says: `${name} is back under the same name, restored from ${record.where}` };
231 }
232
233 // ---- telling a claimant before their forge is proposed ----
234
235 const noticesPath = (app: App): string => join(root(app), "notices.json");
236
237 /** When each claimant was told, by forge name. */
238 export function readNotices(app: App): Record<string, string> {
239 try {
240 const v: unknown = JSON.parse(readFileSync(noticesPath(app), "utf8"));
241 return typeof v === "object" && v !== null ? (v as Record<string, string>) : {};
242 } catch {
243 return {};
244 }
245 }
246
247 function writeNotices(app: App, notices: Record<string, string>): void {
248 mkdirSync(root(app), { recursive: true });
249 writeFileSync(noticesPath(app), JSON.stringify(notices));
250 }
251
252 function forgetNotice(app: App, name: string): void {
253 const n = readNotices(app);
254 if (name in n) {
255 delete n[name];
256 writeNotices(app, n);
257 }
258 }
259
260 /**
261 * Tell the claimant of each claimed forge untouched for 30 days that it will be proposed for
262 * archive in a
263 * week, by mail to the address on their account there. A notice is kept only when the mail went: a
264 * forge whose owner could not be told is never proposed. The address is read from the forge's own
265 * data, which a hosted forge writes: so one mail goes per forge, ever, and at most NOTICES_PER_PASS
266 * in a pass, and what it says is this host's own words, never the forge's. Returns the names told.
267 */
268 const NOTICES_PER_PASS = 5;
269
270 export async function noticeOwners(app: App, rows: readonly ClientRow[], now: number): Promise<string[]> {
271 const notices = readNotices(app);
272 const due = rows.filter((r) => needsNotice({ ...r, noticedAt: notices[r.name] ?? null }, now)).slice(0, NOTICES_PER_PASS);
273 if (due.length === 0) return [];
274 const children = await listChildren(app);
275 const admin = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE is_admin = 1 AND is_agent = 0 ORDER BY created_at LIMIT 1").get();
276 if (admin === null) return [];
277 const mailer = mailerOf(app, admin);
278 if (mailer === undefined) return [];
279 const told: string[] = [];
280 for (const r of due) {
281 const person = await childPerson(childDirOf(app, children.find((c) => c.name === r.name)?.bodyId ?? ""));
282 if (person?.claimantEmail == null) continue;
283 const sent = await mailer({
284 to: person.claimantEmail,
285 subject: `Your forge ${r.name} will be archived`,
286 text: `Nobody has used your forge ${r.name} for 30 days. In a week the host may archive it: its data is kept in a backup and can be restored under the same name. Sign in to ${r.url} to keep it.`,
287 }).catch(() => false);
288 if (!sent) continue;
289 notices[r.name] = new Date(now).toISOString();
290 told.push(r.name);
291 app.bus.append({ kind: "forge.archive_noticed", payload: { name: r.name } });
292 }
293 if (told.length > 0) writeNotices(app, notices);
294 return told;
295 }
src/spawn/archive_proposal.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * Which hosted forges the host is PROPOSED to archive (#12262): never done by
3 * itself. A forge nobody has touched for 30 days and nobody claimed is
4 * proposed at once. A claimed forge belongs to its claimant: it is proposed
5 * only after they were told (`needsNotice`, then the notice's week), and not
6 * if they came back since. Plain data in, plain data out.
7 */
8
9 import type { ClientRow } from "./clients.ts";
10
11 export const UNTOUCHED_MS = 30 * 86_400_000;
12 /** How long a claimant has after being told before the forge is proposed. */
13 export const NOTICE_MS = 7 * 86_400_000;
14
15 export interface ArchiveProposal {
16 readonly why: string;
17 /** The recommended choice, the same word the button carries. */
18 readonly recommended: "Archive";
19 /** When the claimant was told; null for a forge nobody claimed. */
20 readonly ownerTold: string | null;
21 }
22
23 type Facts = Pick<ClientRow, "made" | "claimed" | "lastActivityAt" | "claimedAt" | "sentAt"> & { readonly noticedAt?: string | null | undefined };
24
25 const ms = (iso: string | null | undefined): number => (iso === null || iso === undefined ? NaN : Date.parse(iso));
26
27 /** When anything last touched it: a person, else its claim, else when it was sent. */
28 export function touchedAt(r: Facts): number {
29 const t = [r.lastActivityAt, r.claimedAt, r.sentAt].map(ms).filter((n) => !Number.isNaN(n));
30 return t.length === 0 ? NaN : Math.max(...t);
31 }
32
33 const days = (n: number): string => `${Math.floor(n / 86_400_000)} days`;
34
35 export function archiveProposal(r: Facts, now: number): ArchiveProposal | null {
36 if (!r.made || r.claimed === null) return null;
37 const touched = touchedAt(r);
38 if (Number.isNaN(touched) || now - touched < UNTOUCHED_MS) return null;
39 if (r.claimed === false)
40 return { why: `unclaimed and untouched for ${days(now - touched)}`, recommended: "Archive", ownerTold: null };
41 const told = ms(r.noticedAt);
42 if (Number.isNaN(told) || told < touched || now - told < NOTICE_MS) return null;
43 return {
44 why: `claimed, untouched for ${days(now - touched)}, and its owner was told on ${r.noticedAt!.slice(0, 10)}`,
45 recommended: "Archive",
46 ownerTold: r.noticedAt!,
47 };
48 }
49
50 /**
51 * A claimed forge untouched for 30 days whose owner has not been told since: the one to tell
52 * before it is proposed.
53 */
54 export function needsNotice(r: Facts, now: number): boolean {
55 if (!r.made || r.claimed !== true) return false;
56 const touched = touchedAt(r);
57 if (Number.isNaN(touched) || now - touched < UNTOUCHED_MS) return false;
58 const told = ms(r.noticedAt);
59 return Number.isNaN(told) || told < touched;
60 }
src/spawn/child_db.ts +9
@@ -16,6 +16,7 @@
1616
1717 import { during } from "../perf/jobs.ts";
1818 import type { ChildAsk, ChildJob, ChildReply } from "./child_db_worker.ts";
19 import { readingOf, type PersonReading } from "./person_activity.ts";
1920 import type { Presented } from "./vouch_db.ts";
2021
2122 /** A request's wait for "is this session the child's": past it the request is a stranger's to the wake limits. */
@@ -145,6 +146,14 @@ export async function childCrewAtWork(dir: string, now = Date.now(), ms = SWEEP_
145146 return (await askChild(dir, { kind: "crewAtWork", now }, ms))?.result === true;
146147 }
147148
149 /**
150 * When a person last did something on the child, and who claimed it (`person_activity.ts`);
151 * undefined when it cannot be read in time.
152 */
153 export async function childPerson(dir: string, ms = SWEEP_MS): Promise<PersonReading | undefined> {
154 return readingOf((await askChild(dir, { kind: "personActivity" }, ms))?.result);
155 }
156
148157 /** When the child's earliest paused run falls due (`cell.ts` `nextRunDue`), ms; undefined when none or unread. */
149158 export async function childNextRunDue(dir: string, ms = SWEEP_MS): Promise<number | undefined> {
150159 const at = (await askChild(dir, { kind: "nextRunDue" }, ms))?.result;
src/spawn/child_db_worker.ts +4
@@ -11,6 +11,7 @@ import { Database } from "bun:sqlite";
1111 import { existsSync } from "node:fs";
1212 import { join } from "node:path";
1313 import { crewAtWork, nextRunDue } from "./child_runs.ts";
14 import { ACTIVITY_TABLES, personReading } from "./person_activity.ts";
1415 import { vouchedWith, type Presented } from "./vouch_db.ts";
1516
1617 declare const self: Worker;
@@ -21,6 +22,7 @@ export type ChildAsk =
2122 | { kind: "nextRunDue" }
2223 | { kind: "publicSlugs" }
2324 | { kind: "subscription"; name: string }
25 | { kind: "personActivity" }
2426 | { kind: "counts" };
2527
2628 export type ChildJob = { id: number; dir: string; ask: ChildAsk };
@@ -33,6 +35,7 @@ export const TABLES: Readonly<Record<ChildAsk["kind"], readonly string[]>> = {
3335 nextRunDue: ["run"],
3436 publicSlugs: ["app"],
3537 subscription: ["subscription"],
38 personActivity: ACTIVITY_TABLES,
3639 counts: ["user", "repo", "issue"],
3740 };
3841
@@ -57,6 +60,7 @@ export function answerChild(dir: string, ask: ChildAsk): { result?: unknown; ref
5760 const odd = notPlainTables(db, TABLES[ask.kind]);
5861 if (odd.length > 0) return { refused: `not a plain table: ${odd.join(", ")}` };
5962 if (ask.kind === "vouch") return { result: vouchedWith(db, ask.presented, ask.now) };
63 if (ask.kind === "personActivity") return { result: personReading(db) };
6064 if (ask.kind === "publicSlugs")
6165 return { result: db.query<{ slug: unknown }, []>("SELECT slug FROM app WHERE access = 'public' LIMIT 1000").all().map((r) => r.slug).filter((s): s is string => typeof s === "string") };
6266 if (ask.kind === "counts") {
src/spawn/clients.ts +44 −16
@@ -28,7 +28,11 @@ import { SIZES, type Size } from "./sizes.ts";
2828 import { firstScreenLine } from "../core/next-steps.ts";
2929 import { lapseFor } from "../billing/lapse.ts";
3030 import { lastStop, stopSentence } from "./stopped.ts";
31 import { forgeUse, type ForgeUse } from "./usage.ts";
31 import { forgeUse, keepBytes, type ForgeUse } from "./usage.ts";
32 import { childPerson } from "./child_db.ts";
33 import { readNotices } from "./archive.ts";
34 import { personReading } from "./person_activity.ts";
35 import { archiveProposal, type ArchiveProposal } from "./archive_proposal.ts";
3236 import { appLinkLines, type AppLinkLine } from "./app_links.ts";
3337
3438 /** What a spawned forge says about itself to anyone: nothing more. */
@@ -51,7 +55,23 @@ export interface ClientRow {
5155 /** False while the child is still being made (a resumed spawn finishes it). */
5256 readonly made: boolean;
5357 readonly claimed: boolean | null;
58 /**
59 * When a person or an agent last did something there (`person_activity.ts`), read from its own
60 * tables: never a heartbeat or telemetry.
61 */
5462 readonly lastActivityAt: string | null;
63 /** Who claimed it and when; null while unclaimed or unread. */
64 readonly claimedBy?: string | null;
65 readonly claimedAt?: string | null;
66 /**
67 * What keeping it costs on disk, bytes (its data directory's last reading); null before the
68 * first.
69 */
70 readonly keepBytes?: number | null;
71 /** Why it is proposed for archive, when it is (`spawn/archive_proposal.ts`). */
72 readonly archive?: ArchiveProposal | null;
73 /** When its claimant was told it may be archived, ISO; null when not. */
74 readonly noticedAt?: string | null;
5575 /** Whether its process is running now; a sleeping forge costs nothing and wakes on its next request. */
5676 readonly awake?: boolean;
5777 /** Why the host stopped it, while that explains its state (spawn/stopped.ts); a sentence. */
@@ -148,18 +168,8 @@ export async function forgeState(
148168 // Its host has just proven it in a cell of its own and left the mark: the owner is told now, once
149169 // (`spawn/sandboxed.ts`).
150170 announceSandboxed(app);
151 // Anything that happened: an event, a tell or a change touched, a run,
152 // an account made (the claim is one).
153 const last = app.db
154 .query<{ at: string | null }, []>(
155 `SELECT MAX(at) AS at FROM (
156 SELECT MAX(created_at) AS at FROM event
157 UNION ALL SELECT MAX(updated_at) FROM issue
158 UNION ALL SELECT MAX(created_at) FROM run
159 UNION ALL SELECT MAX(created_at) FROM user WHERE kind = 'user' AND is_agent = 0
160 )`,
161 )
162 .get();
171 // What a person did, never what the forge did by itself (`person_activity.ts`).
172 const last = { at: personReading(app.db).lastAt };
163173 return { claimed, lastActivityAt: last?.at ?? null, link: linkOf(app.db) };
164174 }
165175
@@ -254,7 +264,11 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P
254264 }),
255265 );
256266 const beats = await Promise.all(children.map((c) => lastHeard(app, c.name)));
267 // Who did what there, read from each forge's own tables (asleep or awake, on any code), never
268 // from its word.
269 const people = await Promise.all(children.map((c) => (c.done ? childPerson(childDirOf(app, c.bodyId)) : undefined)));
257270 const links = await Promise.all(children.map((c) => (c.apps.length === 0 ? [] : appLinkLines(app, c))));
271 const notices = readNotices(app);
258272 // Asked once, and only when a forge here is not in a cell: what its row says depends on whether
259273 // this host could hold it in one.
260274 const cells: CellsVerdict = children.some((c) => c.placement !== "cell") ? await cellsHere() : { ok: true };
@@ -271,7 +285,11 @@ export async function clientRows(app: App, options: { probe?: boolean } = {}): P
271285 sentAt: c.createdAt,
272286 made: c.done,
273287 claimed: states[i]?.claimed ?? null,
274 lastActivityAt: states[i]?.lastActivityAt ?? null,
288 lastActivityAt: people[i]?.lastAt ?? null,
289 claimedBy: people[i]?.claimedBy ?? null,
290 claimedAt: people[i]?.claimedAt ?? null,
291 noticedAt: notices[c.name] ?? null,
292 keepBytes: keepBytes(childDirOf(app, c.bodyId)) ?? null,
275293 awake: childAwake(app, c.name),
276294 stopped: stoppedWords(app, c),
277295 running: hostedChildApps(app, c.name) ?? null,
@@ -350,6 +368,16 @@ export async function servedRows(app: App): Promise<ClientRow[]> {
350368 return have.rows;
351369 }
352370
371 /** The rows the last pass read, at once, or none before a pass has; never reads a child. */
372 export function servedNow(app: App): readonly ClientRow[] {
373 return served.get(app)?.rows ?? [];
374 }
375
376 /** The forges proposed for archive, from the last pass: what the needs-you line says (#12262). */
377 export function archiveNeeds(app: App): ClientRow[] {
378 return servedNow(app).filter((r) => r.archive !== null && r.archive !== undefined);
379 }
380
353381 /** A forge was changed here (stopped, removed, a link made): the next list is read afresh. */
354382 export function dropServed(app: App): void {
355383 served.delete(app);
@@ -431,10 +459,10 @@ export function stageOf(r: Omit<ClientRow, "attention" | "stage">, now: number):
431459 }
432460
433461 /** Rows that need the owner first, by reason; then by last activity, newest first. */
434 export function orderRows(rows: readonly Omit<ClientRow, "attention" | "stage">[], now: number): ClientRow[] {
462 export function orderRows(rows: readonly Omit<ClientRow, "attention" | "stage" | "archive">[], now: number): ClientRow[] {
435463 const tier = (a: Attention | null) => (a === null ? ATTENTION_ORDER.length : ATTENTION_ORDER.indexOf(a));
436464 return rows
437 .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now), stage: stageOf(r as ClientRow, now) }))
465 .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now), stage: stageOf(r as ClientRow, now), archive: archiveProposal(r as ClientRow, now) }))
438466 .sort(
439467 (a, b) =>
440468 tier(a.attention) - tier(b.attention) ||
src/spawn/keep_words.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * What a hosted forge's row says about use and cost (#12262), in the same
3 * words on /forges, the terminal and the MCP answer: when a person last did
4 * something there, who claimed it, and what it costs to keep.
5 */
6
7 import type { ClientRow } from "./clients.ts";
8 import { usdWords } from "./credit.ts";
9 import { useWords } from "./usage.ts";
10
11 /** "claimed by maria on 2026-11-02"; null while it is not claimed or unread. */
12 export function claimedWords(r: Pick<ClientRow, "claimedBy" | "claimedAt">): string | null {
13 if (r.claimedBy === null || r.claimedBy === undefined) return null;
14 return r.claimedAt === null || r.claimedAt === undefined ? `claimed by ${r.claimedBy}` : `claimed by ${r.claimedBy} on ${r.claimedAt.slice(0, 10)}`;
15 }
16
17 export function bytesWords(n: number): string {
18 if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(1)} GiB`;
19 return `${Math.max(1, Math.round(n / 1_048_576))} MiB`;
20 }
21
22 /**
23 * "keeps 120 MiB · memory 80 MiB of 512 MiB · $3.20 of $20 credit spent"; what each costs the
24 * host.
25 */
26 export function keepWords(r: Pick<ClientRow, "keepBytes" | "use" | "awake" | "creditSpentUsd" | "creditCapUsd" | "made">): string | null {
27 if (!r.made) return null;
28 const parts = [
29 r.keepBytes === null || r.keepBytes === undefined ? "disk measuring" : `keeps ${bytesWords(r.keepBytes)}`,
30 r.awake === true && r.use.kind === "cell" ? `memory ${useWords(r.use).memory}` : r.awake === true ? "memory shared" : "no memory (asleep)",
31 r.creditSpentUsd === null || r.creditSpentUsd === undefined ? "no credit lent" : `${usdWords(r.creditSpentUsd)} credit spent`,
32 ];
33 return parts.join(" · ");
34 }
35
36 export type ForgeSort = "attention" | "quiet" | "recent" | "cost";
37 export const SORT_WORDS: Record<ForgeSort, string> = {
38 attention: "Needs you first",
39 quiet: "Quietest first",
40 recent: "Most recent activity",
41 cost: "Costliest to keep",
42 };
43 export const isForgeSort = (v: unknown): v is ForgeSort => typeof v === "string" && v in SORT_WORDS;
44
45 const when = (r: ClientRow): number => {
46 const t = Date.parse(r.lastActivityAt ?? "");
47 return Number.isNaN(t) ? 0 : t;
48 };
49
50 /** The rows in the order asked; "attention" keeps the order they came in. */
51 export function sortForges(rows: readonly ClientRow[], by: ForgeSort): ClientRow[] {
52 const out = [...rows];
53 if (by === "quiet") out.sort((a, b) => when(a) - when(b) || a.name.localeCompare(b.name));
54 else if (by === "recent") out.sort((a, b) => when(b) - when(a) || a.name.localeCompare(b.name));
55 else if (by === "cost") out.sort((a, b) => (b.keepBytes ?? 0) - (a.keepBytes ?? 0) || (b.creditSpentUsd ?? 0) - (a.creditSpentUsd ?? 0) || a.name.localeCompare(b.name));
56 return out;
57 }
src/spawn/person_activity.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * When a PERSON last did something on a forge, and who claimed it (#12262).
3 * What counts: a sign-in, a token used (a push over https), an account made,
4 * an issue or pull opened, a comment, a run started by a person. What does
5 * not: heartbeats, telemetry, the engine's own ticks and anything the crew
6 * (an agent) did, none of which has a person behind it. The placeholder
7 * founder a start is born with is no one's doing: an account counts only
8 * when it is an admin's, which is what the claim makes. Read from the
9 * forge's own tables, so a sleeping forge and one on older code answer the
10 * same. Plain data in, plain data out.
11 */
12
13 import type { Database } from "bun:sqlite";
14
15 export interface PersonReading {
16 /** The newest thing a person did, ISO; null when no person ever has. */
17 readonly lastAt: string | null;
18 /** The first person who is an admin there: whoever claimed it; null while unclaimed. */
19 readonly claimedBy: string | null;
20 /** When that account was made, ISO. */
21 readonly claimedAt: string | null;
22 /** Where to tell them, for a notice; never shown. */
23 readonly claimantEmail: string | null;
24 }
25
26 /** The tables the reading opens: each must be a plain table in the child's database. */
27 export const ACTIVITY_TABLES = ["user", "session", "token", "issue", "comment", "run"] as const;
28
29 const PERSON = "u.kind = 'user' AND u.is_agent = 0";
30
31 export function personReading(db: Database): PersonReading {
32 const last = db
33 .query<{ at: string | null }, []>(
34 `SELECT MAX(at) AS at FROM (
35 SELECT MAX(s.created_at) AS at FROM session s JOIN user u ON u.id = s.user_id WHERE ${PERSON}
36 UNION ALL SELECT MAX(t.last_used_at) FROM token t JOIN user u ON u.id = t.user_id WHERE ${PERSON}
37 UNION ALL SELECT MAX(i.created_at) FROM issue i JOIN user u ON u.id = i.author_id WHERE ${PERSON}
38 UNION ALL SELECT MAX(c.created_at) FROM comment c JOIN user u ON u.id = c.author_id WHERE ${PERSON}
39 UNION ALL SELECT MAX(r.created_at) FROM run r JOIN user u ON u.id = r.actor_id WHERE ${PERSON}
40 UNION ALL SELECT MAX(u.created_at) FROM user u WHERE ${PERSON} AND u.is_admin = 1
41 )`,
42 )
43 .get();
44 const who = db
45 .query<{ username: string; created_at: string; email: string }, []>(
46 `SELECT u.username, u.created_at, u.email FROM user u WHERE ${PERSON} AND u.is_admin = 1 ORDER BY u.created_at, u.id LIMIT 1`,
47 )
48 .get();
49 return {
50 lastAt: last?.at ?? null,
51 claimedBy: who?.username ?? null,
52 claimedAt: who?.created_at ?? null,
53 claimantEmail: who?.email ?? null,
54 };
55 }
56
57 /**
58 * A reading from an answer that may be anything (the worker's reply): the fields it names, or
59 * undefined.
60 */
61 export function readingOf(v: unknown): PersonReading | undefined {
62 if (typeof v !== "object" || v === null) return undefined;
63 const r = v as Record<string, unknown>;
64 const s = (x: unknown): string | null => (typeof x === "string" && x !== "" ? x : null);
65 return { lastAt: s(r["lastAt"]), claimedBy: s(r["claimedBy"]), claimedAt: s(r["claimedAt"]), claimantEmail: s(r["claimantEmail"]) };
66 }
src/spawn/usage.ts +6
@@ -57,6 +57,12 @@ function diskOf(dir: string, now: number): number | undefined {
5757 return kept?.bytes;
5858 }
5959
60 /**
61 * What a forge's data directory holds now, bytes: the last reading, or undefined before the first
62 * (it is taken off the request).
63 */
64 export const keepBytes = (dir: string): number | undefined => diskOf(dir, Date.now());
65
6066 /** One child's use; `dir` is where its data lives, `now` the clock. */
6167 export function forgeUse(child: Child, dir?: string, now = Date.now()): ForgeUse {
6268 if (child.placement !== "cell") return { kind: "shared" };
src/tui/screens/forges.ts +31 −3
@@ -14,6 +14,8 @@ import { drawFacts, runAsync } from "./common.ts";
1414 import type { PaletteEntry } from "./entries.ts";
1515 import { clientRows } from "../../spawn/clients.ts";
1616 import { diskWords } from "../../spawn/disk_mount.ts";
17 import { archiveForge, archivedForges, destinationWords, restoreForge } from "../../spawn/archive.ts";
18 import { claimedWords, keepWords } from "../../spawn/keep_words.ts";
1719 import { changeLink, ownCode, removeForge, stopForge } from "../../spawn/manage.ts";
1820 import { moveToCell } from "../../spawn/move_cell.ts";
1921 import { readOwnCode } from "../../spawn/own_code.ts";
@@ -28,7 +30,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2830 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2931 return rows.flatMap((r): [string, string][] => [[
3032 r.name,
31 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
33 [r.made ? (r.awake === true ? "awake" : "asleep") : null, r.made && r.where.inProcess ? r.where.says : null, r.stopped ?? null, r.attention === null ? null : ATTENTION_WORDS[r.attention], r.for === null ? null : `for ${r.for}`, CLAIM_WORDS[claimState(r)], linkWords(r.link), runningWords(r), r.stage?.words ?? null, r.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), graceWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.made ? (r.lastActivityAt === null ? "no person has done anything here" : `a person last did something ${timeAgo(r.lastActivityAt)}`) : null, claimedWords(r), keepWords(r), r.archive === null || r.archive === undefined ? null : `proposed for archive: ${r.archive.why}`, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
3234 .filter((m): m is string => m !== null)
3335 .join(" · "),
3436 ], ...firstScreenLines(r), ...appLinkLines(r), ...addressLines(r)]);
@@ -145,7 +147,7 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses
145147 };
146148 const screen: Screen = {
147149 title: "Forges you sent",
148 keys: [binding(["s"], "stop one (sleep now)"), binding(["x"], "remove one"), binding(["l"], "end a link"), binding(["u"], "undo an ended link"), binding(["o"], "own code or this forge's"), binding(["m"], "move into a sandbox"), binding(["esc"], "close")],
150 keys: [binding(["s"], "stop one (sleep now)"), binding(["a"], "archive"), binding(["r"], "restore"), binding(["x"], "remove one"), binding(["l"], "end a link"), binding(["u"], "undo an ended link"), binding(["o"], "own code or this forge's"), binding(["m"], "move into a sandbox"), binding(["esc"], "close")],
149151 render(area, s) {
150152 const c = ctx.theme.c;
151153 const w = Math.min(100, Math.max(30, area.w - 6));
@@ -162,7 +164,33 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[], session?: Ses
162164 else if (e.key === "l") changeOne("revoke");
163165 else if (e.key === "u") changeOne("undo");
164166 else if (e.key === "o") switchCode();
165 else if (e.key === "m") sandboxOne();
167 else if (e.key === "a" && session !== undefined)
168 choose("Archive which forge?", (name) =>
169 ctx.push(
170 confirmScreen(ctx, screen, {
171 title: `Archive ${name}?`,
172 body: [`${name} is stopped, its data and ledger are written as one file to ${destinationWords(session.app)}, then it is removed. Nothing is removed if the copy cannot be verified; restore brings it back under the same name.`],
173 actions: [{ label: "Archive it", variant: "danger" }, { label: "Keep it" }],
174 onChoose: (i) => {
175 if (i === 0) void archiveForge(session.app, name, session.user === undefined ? {} : { actorId: session.user.id }).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger"));
176 },
177 }),
178 ),
179 );
180 else if (e.key === "r" && session !== undefined && session.user?.isAdmin === true) {
181 const user = session.user;
182 const items = archivedForges(session.app).map((r) => ({ id: r.name, label: r.name, hint: `archived ${r.archivedAt.slice(0, 10)}, ${(r.bytes / 1_048_576).toFixed(1)} MiB at ${r.where}` }));
183 if (items.length === 0) { ctx.toast("No forge is archived"); return true; }
184 ctx.push(
185 pickerScreen(ctx, screen, {
186 title: "Restore which forge?",
187 items,
188 onPick: (ids) => {
189 if (ids[0] !== undefined) void restoreForge(session.app, ids[0], { actorId: user.id }).then((done) => ctx.toast(done.says, done.ok ? "success" : "danger"));
190 },
191 }),
192 );
193 } else if (e.key === "m") sandboxOne();
166194 else if (e.key === "x" && session !== undefined)
167195 choose("Remove which forge?", (name) =>
168196 ctx.push(
src/web/pages/forges.ts +104 −5
@@ -29,6 +29,9 @@ import { claimLinkBody } from "./new-forge.ts";
2929 import { appLinkSummary, appLinkWords } from "../../spawn/app_links.ts";
3030 import { REACH_SETTINGS, reachByForge, type ForgeReach } from "../../actions/child_reach.ts";
3131 import { forgeReachRows } from "./hosted-forges.ts";
32 import { archiveForge, archivedForges, restoreForge, type ArchiveRecord } from "../../spawn/archive.ts";
33 import { claimedWords, isForgeSort, keepWords, SORT_WORDS, sortForges, type ForgeSort } from "../../spawn/keep_words.ts";
34 import { CHILD_NAME } from "../../spawn/placement.ts";
3235 import { forgesMemory, memoryOverLine } from "../../spawn/memory_fit.ts";
3336
3437
@@ -70,15 +73,16 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
7073 r.stage === null
7174 ? null
7275 : html`<span data-stage="${r.stage.key}"${r.stage.resend ? html` data-resend` : null}>${r.stage.words}${r.stage.at === null ? null : html` ${time(r.stage.at)}`}</span>`,
73 r.lastActivityAt === null
74 ? null
75 : html`last activity ${time(r.lastActivityAt)}`,
76 r.made ? (r.lastActivityAt === null ? html`<span data-activity>no one has done anything here yet</span>` : html`<span data-activity>a person last did something ${time(r.lastActivityAt)}</span>`) : null,
77 claimedWords(r) === null ? null : html`<span data-claimed-by>${claimedWords(r)}</span>`,
78 keepWords(r) === null ? null : html`<span data-keep>${keepWords(r)}</span>`,
79 r.noticedAt === null || r.noticedAt === undefined ? null : html`<span data-noticed>owner told ${time(r.noticedAt)}</span>`,
7680 badge(r.nextStep.key === "going" ? html`going: last tell ${time(r.nextStep.lastTellAt!)}` : r.nextStep.words, {
7781 variant: "outline",
7882 }),
7983 ].filter((m): m is Html => m !== null);
8084 return row({
81 title: html`<a href="${r.url}">${r.name}</a>`,
85 title: html`${r.made ? html`<input type="checkbox" name="name" value="${r.name}" form="forges-archive-form" aria-label="Select ${r.name} to archive" /> ` : null}<a href="${r.url}">${r.name}</a>`,
8286 meta: html`${meta.map((m, i) => html`${i > 0 ? " · " : ""}${m}`)}`,
8387 // Enter opens the row's first link, the forge's name.
8488 nav: true,
@@ -86,6 +90,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
8690 id: `forge-${r.name}`,
8791 data: { forge: r.name, claimed: state },
8892 children: [
93 ...(r.archive === null || r.archive === undefined ? [] : [row({ title: html`Proposed for archive: ${r.archive.why}. Recommended: ${r.archive.recommended}.`, trail: btn("Archive", { href: `/forges/archive?name=${r.name}`, size: "sm", variant: "primary" }), data: { proposal: "archive" } })]),
8994 // What it asked to reach and what it may: answered here, first under its name.
9095 ...forgeReachRows(reach, `/forges#forge-${r.name}`),
9196 ...(firstScreenOf(r) === null ? [] : [row({ title: html`${firstScreenOf(r)}`, data: { "first-screen": "" } })]),
@@ -128,6 +133,7 @@ export function forgeRow(r: ClientRow, now: number, reach?: ForgeReach): Html {
128133 </form>`
129134 : null}${r.made
130135 ? html`<form method="post" action="/forges/${r.name}/stop" class="inline">${btn("Stop", { size: "sm", title: "Put it to sleep now; its next visitor wakes it" })}</form>
136 ${btn("Archive", { href: `/forges/archive?name=${r.name}`, size: "sm", title: "Saves its data and ledger as one file to the backup destination, then removes it; Restore brings it back" })}
131137 ${btn("Remove", { href: `/forges/${r.name}/remove`, size: "sm", title: "Asks first, then deletes this forge and its data for good" })}`
132138 : null}`,
133139 });
@@ -190,7 +196,10 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
190196 const user = ctx.auth.user;
191197 if (!user?.isAdmin) return notFoundPage(ctx);
192198 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
193 const rows = await timePhaseAsync(ctx.request, "clients", () => servedRows(ctx.app));
199 const asked = ctx.url.searchParams.get("sort");
200 const sort: ForgeSort = isForgeSort(asked) ? asked : "attention";
201 const rows = sortForges(await timePhaseAsync(ctx.request, "clients", () => servedRows(ctx.app)), sort);
202 const archived = archivedForges(ctx.app);
194203 const full = timePhase(ctx.request, "room", () => noRoom(ctx.app));
195204 // Two small tables, by name: the rows themselves come from memory, and this reads no ledger.
196205 const reachOf = timePhase(ctx.request, "reach", () => reachByForge(ctx.app, user));
@@ -223,11 +232,16 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
223232 </section>
224233 <section class="settings-block">
225234 <h2>Forges</h2>
235 <nav class="muted small" aria-label="Sort forges" data-sorts>Sort: ${(Object.keys(SORT_WORDS) as ForgeSort[]).map((k, i) => html`${i > 0 ? " · " : ""}${k === sort ? html`<strong aria-current="true">${SORT_WORDS[k]}</strong>` : html`<a href="/forges?sort=${k}">${SORT_WORDS[k]}</a>`}`)}</nav>
236 <form id="forges-archive-form" method="get" action="/forges/archive" class="mt">
237 ${btn("Archive selected", { size: "sm", title: "Pick forges with the boxes; the next page says what happens before anything does" })}
238 </form>
226239 <ul class="list setting-list">
227240 ${rows.map((r) => forgeRow(r, Date.now(), reachOf.get(r.name)))}
228241 </ul>
229242 </section>`}
230243 ${sealedLine(ctx)}
244 ${archivedSection(ctx, archived)}
231245 ${await ownCodeSection(ctx)}
232246 <p class="mt"><a href="/fleet/graph">Fleet graph</a> <a href="${REACH_SETTINGS}">What hosted forges may reach</a>${refusedLink(ctx)}</p>
233247 </div>`,
@@ -236,6 +250,91 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
236250 return response;
237251 }
238252
253 /** One line each for what was archived: where the copy is, and the way back. */
254 function archivedSection(ctx: Ctx, archived: readonly ArchiveRecord[]): Html | null {
255 const said = ctx.url.searchParams.get("archive");
256 const note = said === null ? null : html`<p role="status" class="muted" data-receipt>${said.slice(0, 300)}</p>`;
257 if (archived.length === 0) return note === null ? null : html`<section class="settings-block" id="archived"><h2>Archived</h2>${note}</section>`;
258 return html`<section class="settings-block" id="archived">
259 <h2>Archived</h2>
260 ${note}
261 <ul class="list setting-list">
262 ${archived.map((a) =>
263 row({
264 title: html`${a.name}`,
265 meta: html`archived ${time(a.archivedAt)} · copy at ${a.where} (${a.key}) · ${a.for === null ? "no one named" : `for ${a.for}`}`,
266 id: `archived-${a.name}`,
267 data: { archived: a.name },
268 trail: html`<form method="post" action="/forges/${a.name}/restore" class="inline">${btn("Restore", { size: "sm", title: "Brings it back under the same name" })}</form>`,
269 }),
270 )}
271 </ul>
272 </section>`;
273 }
274
275 /** The names a request asked to archive: valid names of forges that exist, at most 50. */
276 async function namedForges(ctx: Ctx, wanted: readonly string[]): Promise<string[]> {
277 const have = new Set((await listChildren(ctx.app)).filter((c) => c.done).map((c) => c.name));
278 return [...new Set(wanted)].filter((n) => CHILD_NAME.test(n) && have.has(n)).slice(0, 50);
279 }
280
281 // GET /forges/archive?name=a&name=b — what archiving does, for one or several, and the one button
282 // that does it.
283 export async function forgeArchiveConfirm(ctx: Ctx): Promise<Response> {
284 const user = ctx.auth.user;
285 if (!user?.isAdmin) return notFoundPage(ctx);
286 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
287 const names = await namedForges(ctx, ctx.url.searchParams.getAll("name"));
288 const rows = (await servedRows(ctx.app)).filter((r) => names.includes(r.name));
289 const rendering = performance.now();
290 const response = page({
291 ctx,
292 title: "Archive forges?",
293 body: html`<div class="narrow-wide" data-forges>
294 ${pageHead("Archive forges?")}
295 <section class="settings-block">
296 ${rows.length === 0
297 ? html`<p>Pick at least one forge on ${html`<a href="/forges">your forges</a>`}.</p>`
298 : html`<h2>What archiving does</h2>
299 <p>Each forge below is stopped, its data directory and ledger are written as one file to this forge's backup destination (and kept here until that copy is read back and matches), and then it is removed. A line stays under Archived with where the copy is, and Restore brings it back under the same name. If the copy cannot be verified, nothing is removed.</p>
300 <ul class="list setting-list">
301 ${rows.map((r) => row({ title: html`${r.name}`, meta: html`${[r.lastActivityAt === null ? "no person has done anything" : "last activity " + r.lastActivityAt.slice(0, 10), claimedWords(r), keepWords(r)].filter((x) => x !== null).join(" · ")}` }))}
302 </ul>
303 <form method="post" action="/forges/archive" class="mt">
304 ${rows.map((r) => html`<input type="hidden" name="name" value="${r.name}" />`)}
305 ${btn("Archive", { variant: "primary" })}
306 ${btn("Keep them", { href: "/forges" })}
307 </form>`}
308 </section>
309 </div>`,
310 });
311 notePhase(ctx.request, "render", performance.now() - rendering);
312 response.headers.set("cache-control", "no-store");
313 return response;
314 }
315
316 // POST /forges/archive — archive each named forge, one after another; the receipt says each result.
317 export async function forgeArchiveSubmit(ctx: Ctx): Promise<Response> {
318 const user = ctx.auth.user;
319 if (!user?.isAdmin) return notFoundPage(ctx);
320 const form = await readForm(ctx, "admin");
321 if (form instanceof Response) return form;
322 const names = await namedForges(ctx, form.getAll("name").filter((v): v is string => typeof v === "string"));
323 const says: string[] = [];
324 for (const name of names) says.push((await archiveForge(ctx.app, name, { actorId: user.id })).says);
325 return redirect(`/forges?archive=${encodeURIComponent(says.length === 0 ? "No forge was named." : says.join(" · "))}#archived`);
326 }
327
328 // POST /forges/:name/restore — bring an archived forge back under its name.
329 export async function forgeRestoreSubmit(ctx: Ctx): Promise<Response> {
330 const user = ctx.auth.user;
331 if (!user?.isAdmin) return notFoundPage(ctx);
332 const form = await readForm(ctx, "admin");
333 if (form instanceof Response) return form;
334 const done = await restoreForge(ctx.app, ctx.params["name"] ?? "", { actorId: user.id });
335 return redirect(`/forges?archive=${encodeURIComponent(done.says)}#archived`);
336 }
337
239338 // POST /forges/:name/claim-link — a fresh link for an unclaimed child; the
240339 // old one stops working. Shown once, in this answer only.
241340 export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> {
src/web/pages/home_flow.ts +21 −2
@@ -62,6 +62,7 @@ import { requestsCard } from "../requests_card.ts";
6262 import { askForEveryday } from "../../core/everyday.ts";
6363 import type { ReachAsk } from "../../core/child_reach.ts";
6464 import { reachNeeds } from "../../spawn/child_reach.ts";
65 import { archiveNeeds, type ClientRow } from "../../spawn/clients.ts";
6566 import { reachNeed } from "./hosted-forges.ts";
6667 import { inProcessNeed, whereOf } from "../../spawn/where.ts";
6768 import { untoldAnswer, markTold } from "../../core/requests.ts";
@@ -258,6 +259,8 @@ interface StripViewer {
258259 readonly everyday?: boolean;
259260 /** What hosted forges asked to reach and no admin has answered (`spawn/child_reach.ts`); an admin's only. */
260261 readonly reach?: readonly ReachAsk[];
262 /** Hosted forges proposed for archive (`spawn/archive_proposal.ts`); an admin's only. */
263 readonly archive?: readonly ClientRow[];
261264 /** Forges that run inside this forge's own process and could be in a sandbox; an admin's only. */
262265 readonly inProcess?: number;
263266 }
@@ -265,6 +268,20 @@ interface StripViewer {
265268 /** The everyday account's one quiet line: the step is on its own page, where "Keep using admin" is remembered. */
266269 const EVERYDAY_NEED: Need = { says: "You are signed in as the admin. Make an everyday account", href: "/settings/everyday", tone: "notice" };
267270
271 /**
272 * One line for the forges proposed for archive; its link opens the confirm page with every one
273 * picked.
274 */
275 function archiveNeed(rows: readonly ClientRow[]): Need {
276 const names = rows.map((r) => r.name);
277 const one = names.length === 1;
278 return {
279 says: `${one ? `${names[0]} is` : `${names.length} hosted forges are`} proposed for archive (${rows[0]!.archive!.why}${one ? "" : ", and more"}): archive is recommended`,
280 href: `/forges/archive?${names.map((n) => `name=${encodeURIComponent(n)}`).join("&")}`,
281 tone: "notice",
282 };
283 }
284
268285 function needsOf(picture: LanePicture, viewer: StripViewer, dataDir: string, now: number): Need[] {
269286 const proof = viewer.isAdmin ? proofNotice(dataDir, running().version, now) : undefined;
270287 const asks = picture.asks[viewer.id];
@@ -277,6 +294,8 @@ function needsOf(picture: LanePicture, viewer: StripViewer, dataDir: string, now
277294 ...alarms,
278295 // A person on a hosted forge is stopped until this is answered: first among what can wait.
279296 ...(viewer.isAdmin ? (viewer.reach ?? []).map((a, n) => ({ ...reachNeed(a, n), tone: "notice" as const })) : []),
297 // Proposed, never done by itself: one line, the recommended choice one tap away.
298 ...(viewer.isAdmin && (viewer.archive ?? []).length > 0 ? [archiveNeed(viewer.archive!)] : []),
280299 // Work hidden inside this forge's own process: one line for all of them, and /forges moves
281300 // each.
282301 ...(viewer.isAdmin && inProcessNeed(viewer.inProcess ?? 0) !== null ? [{ says: inProcessNeed(viewer.inProcess ?? 0)!, href: "/forges", tone: "notice" as const }] : []),
@@ -497,7 +516,7 @@ export async function homeFlowPage(ctx: Ctx, known?: { readonly spawned: Awaited
497516 // A spawned forge's owner is a client: the forge-wide figures are not theirs to see, whole home or lean.
498517 const spawned = known !== undefined ? known.spawned : await timePhaseAsync(ctx.request, "spawned", () => spawnedForge(ctx.app));
499518 const wide = user.isAdmin && spawned === undefined;
500 const strip = picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now);
519 const strip = picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), archive: archiveNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now);
501520 // What the old home also carried and is not a lane: the box that tells the crew (with Notes and
502521 // Talk), the welcome after an account is allowed, a
503522 // spawned forge's address, and a stranger's request for an account. The everyday-account ask is
@@ -627,7 +646,7 @@ export async function liveStripFragment(ctx: Ctx): Promise<Response> {
627646 const picture = await timePhaseAsync(ctx.request, "picture", () => lanePicture(ctx.app.db, optionsOf(ctx, user), "lane", now));
628647 const rendering = performance.now();
629648 const wide = user.isAdmin && (await spawnedForge(ctx.app)) === undefined;
630 const res = fragment(picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now), { request: ctx.request });
649 const res = fragment(picture === undefined ? readingStrip() : stripRegion(picture, { id: user.id, isAdmin: wide, everyday: askForEveryday(ctx.app.db, user), ...(wide ? { reach: reachNeeds(ctx.app), archive: archiveNeeds(ctx.app), inProcess: await inProcessForges(ctx.app, now) } : {}) }, ctx.app.config.dataDir, now), { request: ctx.request });
631650 notePhase(ctx.request, "render", performance.now() - rendering);
632651 return res;
633652 }
test/client-list.test.ts +8 −2
@@ -175,7 +175,12 @@ describe("the client list", () => {
175175 });
176176 expect(((await wrong.json()) as Record<string, unknown>)["lastActivityAt"]).toBeNull();
177177 // The parent, asking with the secret it kept, does.
178 const rows = (await (await mcp(adminRw)).json()) as any;
178 // What it says is what a person did there (#12262): the forge's own boot and telemetry are
179 // not that.
180 let rows = (await (await mcp(adminRw)).json()) as any;
181 expect(JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria").lastActivityAt).toBeNull();
182 child.db.run("INSERT INTO session (token_hash, user_id, expires_at) SELECT 'sign-in-1', id, '2099-01-01T00:00:00.000Z' FROM user WHERE kind = 'user' AND is_agent = 0 LIMIT 1");
183 rows = (await (await mcp(adminRw)).json()) as any;
179184 const maria = JSON.parse(rows.result.content[0].text).forges.find((f: any) => f.name === "maria");
180185 expect(typeof maria.lastActivityAt).toBe("string");
181186 // The secret itself is never in an answer.
@@ -357,7 +362,8 @@ describe("the client list", () => {
357362 const dustyApi = body.items.find((r) => r.name === "dusty");
358363 expect(dustyApi.attention).toBe("unclaimed");
359364 expect(dustyApi.claimed).toBe(false);
360 expect(dustyApi).toMatchObject(forges.find((r) => r.name === "dusty"));
365 // The disk reading is taken off the request, so two answers may differ in it alone.
366 expect({ ...dustyApi, keepBytes: 0 }).toMatchObject({ ...forges.find((r) => r.name === "dusty"), keepBytes: 0 });
361367 expect(dustyApi.nextStep.key).toBe("unreported");
362368 // Admin only: a non-admin is 403, a signed-out caller 401.
363369 const bob = await fetch(`${parentServers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${bobRw}` } });
test/did-you-mean.test.ts +1 −1
@@ -15,7 +15,7 @@ let servers: Awaited<ReturnType<typeof serve>>;
1515 /** Statements the page of a found repository makes; a change to it is a change to this number. */
1616 // 41 and 18 until 2026-11-13: every page of a repository runs two statements fewer since
1717 // that day's pushes.
18 const FOUND_QUERIES = 39;
18 const FOUND_QUERIES = 40; // one more: the fork-reach lookup of the repository row
1919 const BARE_404_QUERIES = 3; // a 404 with no owner/name to suggest from
2020 const NEAR_404_QUERIES = 16; // the same 404 for a mistyped repository
2121 const cookies: Record<string, string> = {};
test/forge-archive.test.ts added +353
@@ -0,0 +1,353 @@
1 /**
2 * #12262: which hosted forges are in use, and archiving one: a person's last
3 * activity (never a heartbeat), who claimed it, the proposal after 30 days,
4 * and the archive (one file at the backup destination, then removed) with its
5 * Restore.
6 */
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { existsSync, readdirSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { childDirOf, childNamed, listChildren } from "../src/actions/spawn.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { setStrangerLimits, strangerLimits } from "../src/http/strangers.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { archiveForge, archivedForges, noticeOwners, readNotices, restoreForge } from "../src/spawn/archive.ts";
19 import { archiveProposal, needsNotice, NOTICE_MS, UNTOUCHED_MS } from "../src/spawn/archive_proposal.ts";
20 import { childPerson } from "../src/spawn/child_db.ts";
21 import { clientRows, servedRows, useChildFetch } from "../src/spawn/clients.ts";
22 import { useProbe } from "../src/spawn/door.ts";
23 import { firstBoot } from "../src/spawn/first-boot.ts";
24 import { personReading } from "../src/spawn/person_activity.ts";
25 import { dirStore } from "../src/snapshot/store.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27
28 quietLogs();
29
30 let dir = "";
31 let store = "";
32 let parent: App;
33 let servers: Servers;
34 let token = "";
35 let adminId = "";
36 const mails: { to: string; subject: string }[] = [];
37
38 beforeAll(async () => {
39 dir = await mkdtemp(join(tmpdir(), "forge-archive-"));
40 store = await mkdtemp(join(tmpdir(), "forge-archive-store-"));
41 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0, mailer: async (m) => (mails.push({ to: m.to, subject: m.subject }), true) });
42 useProbe(parent, () => async () => true);
43 useChildFetch(parent, async () => new Response("{}", { status: 503 }));
44 const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
45 if (!alice.ok) throw new Error(alice.error);
46 adminId = alice.value.id;
47 token = createToken(parent.db, adminId, "archive", "admin").token;
48 setStrangerLimits(parent, { perAddress: { ...strangerLimits(parent).perAddress, plan: 1000 } });
49 servers = await serve(parent);
50 }, 30_000);
51
52 afterAll(async () => {
53 servers.stop();
54 parent.close();
55 await rm(dir, { recursive: true, force: true });
56 await rm(store, { recursive: true, force: true });
57 });
58
59 const post = (path: string, body: Record<string, string> | [string, string][] = {}) =>
60 fetch(`${servers.url}${path}`, { method: "POST", redirect: "manual", headers: { origin: "https://forge.test", authorization: `Bearer ${token}` }, body: new URLSearchParams(body) });
61
62 /** A forge the host made, with its own data (an account in it, when `claimed`). */
63 async function makeForge(name: string, claimed: boolean) {
64 const res = await fetch(`${servers.url}/forges/start`, { method: "POST", redirect: "manual", headers: { origin: "https://forge.test" }, body: new URLSearchParams({ name }) });
65 await res.body?.cancel();
66 expect(res.status).toBe(200);
67 const made = (await childNamed(parent, name))!;
68 const childDir = childDirOf(parent, made.bodyId);
69 const child = createApp({ dataDir: childDir, port: 0 });
70 await firstBoot(child);
71 if (claimed) {
72 const u = await createUser(child.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
73 if (!u.ok) throw new Error(u.error);
74 // The claim makes the claimant an admin; the placeholder founder a start is born with is not
75 // one.
76 child.db.run("UPDATE user SET is_admin = 1 WHERE username = 'maria'");
77 }
78 return { made, childDir, child };
79 }
80
81 test("a person's last activity ignores telemetry, heartbeats and the crew, and says who claimed it", async () => {
82 const { child } = await makeForge("in-use", true);
83 const person = personReading(child.db);
84 expect(person.claimedBy).toBe("maria");
85 expect(person.lastAt).not.toBeNull();
86 // Telemetry and an engine's tick, far later than the person: they do not move it.
87 child.db.run("INSERT INTO event (id, kind, payload, created_at) VALUES ('e1', 'telemetry.sample', '{}', '2030-01-01T00:00:00.000Z')");
88 expect(personReading(child.db).lastAt).toBe(person.lastAt);
89 // A person's issue does.
90 const maria = child.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'maria'").get()!;
91 child.db.run("INSERT INTO repo (id, owner_id, name) VALUES ('r1', ?1, 'app')", [maria.id]);
92 child.db.run("INSERT INTO issue (id, repo_id, number, title, author_id, created_at) VALUES ('i1', 'r1', 1, 'hello', ?1, '2031-02-03T04:05:06.000Z')", [maria.id]);
93 expect(personReading(child.db).lastAt).toBe("2031-02-03T04:05:06.000Z");
94 // The host reads the same from outside, through its worker, with the forge asleep.
95 expect((await childPerson(child.config.dataDir))?.lastAt).toBe("2031-02-03T04:05:06.000Z");
96 child.close();
97 });
98
99 test("a forge nobody claimed shows no person, and the row carries what it costs to keep", async () => {
100 const { child } = await makeForge("empty-one", false);
101 child.close();
102 const row = (await clientRows(parent, { probe: false })).find((r) => r.name === "empty-one")!;
103 expect(row.lastActivityAt).toBeNull();
104 expect(row.claimedBy).toBeNull();
105 expect(row.claimed).toBeNull(); // not probed
106 expect("keepBytes" in row).toBe(true);
107 });
108
109 const day = 86_400_000;
110 const facts = (o: Partial<Parameters<typeof archiveProposal>[0]> = {}) => ({ made: true, claimed: false as boolean | null, lastActivityAt: null as string | null, claimedAt: null as string | null, sentAt: "2026-01-01T00:00:00.000Z" as string | null, ...o });
111
112 test("an unclaimed forge untouched for 30 days is proposed with the recommended choice; 29 days is not", () => {
113 const sent = Date.parse("2026-01-01T00:00:00.000Z");
114 const p = archiveProposal(facts(), sent + UNTOUCHED_MS);
115 expect(p).toMatchObject({ recommended: "Archive", ownerTold: null });
116 expect(p!.why).toContain("unclaimed");
117 expect(archiveProposal(facts(), sent + 29 * day)).toBeNull();
118 // A person's visit resets the clock.
119 expect(archiveProposal(facts({ lastActivityAt: "2026-01-20T00:00:00.000Z" }), sent + 40 * day)).toBeNull();
120 // Not asked, still being made: never.
121 expect(archiveProposal(facts({ claimed: null }), sent + 90 * day)).toBeNull();
122 expect(archiveProposal(facts({ made: false }), sent + 90 * day)).toBeNull();
123 });
124
125 test("a claimed forge is never proposed before its owner was told, and not if they came back", () => {
126 const t0 = Date.parse("2026-01-01T00:00:00.000Z");
127 const claimed = facts({ claimed: true, claimedAt: "2026-01-02T00:00:00.000Z" });
128 const late = t0 + 60 * day;
129 expect(archiveProposal(claimed, late)).toBeNull();
130 expect(needsNotice(claimed, late)).toBe(true);
131 expect(needsNotice(claimed, t0 + 10 * day)).toBe(false);
132 const told = new Date(late).toISOString();
133 expect(needsNotice({ ...claimed, noticedAt: told }, late)).toBe(false);
134 expect(archiveProposal({ ...claimed, noticedAt: told }, late + NOTICE_MS - 1)).toBeNull();
135 const p = archiveProposal({ ...claimed, noticedAt: told }, late + NOTICE_MS);
136 expect(p?.ownerTold).toBe(told);
137 // They came back after being told: not proposed, and told again next time.
138 const back = { ...claimed, noticedAt: told, lastActivityAt: new Date(late + 2 * day).toISOString() };
139 expect(archiveProposal(back, late + 20 * day)).toBeNull();
140 });
141
142 test("archive writes one file to the destination, removes the forge, leaves one line; restore brings it back under the same name", async () => {
143 const { made, childDir, child } = await makeForge("old-test", false);
144 child.db.run("INSERT INTO event (id, kind, payload) VALUES ('keep-me', 'marker', '{}')");
145 child.close();
146 const destination = { source: "dir" as const, store: dirStore(store) };
147 const done = await archiveForge(parent, "old-test", { destination, actorId: adminId });
148 expect(done.ok).toBe(true);
149 // Gone from the host: the ledger, the data, the name.
150 expect(await childNamed(parent, "old-test")).toBeUndefined();
151 expect(existsSync(childDir)).toBe(false);
152 expect(existsSync(join(dir, "spawn", "old-test.json"))).toBe(false);
153 // One file at the destination, and one line left.
154 const keys = await destination.store.list("forge-archives/old-test/");
155 expect(keys.length).toBe(1);
156 const [record] = archivedForges(parent);
157 expect(record).toMatchObject({ name: "old-test", where: store, key: keys[0] });
158 // The local copy is not kept once the upload is verified.
159 expect(readdirSync(join(dir, "archive", "files"))).toEqual([]);
160 // The page names where the copy is, with a Restore.
161 const page = await (await fetch(`${servers.url}/forges`, { headers: { authorization: `Bearer ${token}` } })).text();
162 expect(page).toContain('id="archived"');
163 expect(page).toContain(`/forges/old-test/restore`);
164 // Restore: same name, same data, the line gone.
165 const back = await restoreForge(parent, "old-test", { destination });
166 expect(back.ok).toBe(true);
167 expect((await childNamed(parent, "old-test"))?.bodyId).toBe(made.bodyId);
168 const again = createApp({ dataDir: childDir, port: 0 });
169 expect(again.db.query("SELECT id FROM event WHERE id = 'keep-me'").get()).not.toBeNull();
170 again.close();
171 expect(archivedForges(parent)).toEqual([]);
172 await archiveForge(parent, "old-test", { destination }); // tidy
173 expect((await restoreForge(parent, "missing", { destination })).ok).toBe(false);
174 });
175
176 test("a copy that does not read back the same removes nothing", async () => {
177 const { childDir, child } = await makeForge("kept-safe", false);
178 child.close();
179 const real = dirStore(store);
180 const liar = { ...real, async get(key: string) { const b = await real.get(key); return b === undefined ? b : b.slice(0, b.length - 1); } };
181 const done = await archiveForge(parent, "kept-safe", { destination: { source: "dir", store: liar } });
182 expect(done.ok).toBe(false);
183 expect(done.says).toContain("did not read back the same");
184 expect(await childNamed(parent, "kept-safe")).toBeDefined();
185 expect(existsSync(childDir)).toBe(true);
186 // An upload that throws is the same.
187 const down = { ...real, async put() { throw new Error("bucket down"); } };
188 const failed = await archiveForge(parent, "kept-safe", { destination: { source: "dir", store: down } });
189 expect(failed.ok).toBe(false);
190 expect(await childNamed(parent, "kept-safe")).toBeDefined();
191 expect(archivedForges(parent).some((a) => a.name === "kept-safe")).toBe(false);
192 });
193
194 test("with no backup destination nothing is archived", async () => {
195 const saved = process.env["FORGE_SNAPSHOT_DIR"];
196 delete process.env["FORGE_SNAPSHOT_DIR"];
197 try {
198 const done = await archiveForge(parent, "kept-safe");
199 expect(done.ok).toBe(false);
200 expect(done.says).toContain("no backup destination");
201 expect(await childNamed(parent, "kept-safe")).toBeDefined();
202 } finally {
203 if (saved !== undefined) process.env["FORGE_SNAPSHOT_DIR"] = saved;
204 }
205 });
206
207 test("the page asks first, archives several at once, and a stranger or a bad name gets nothing", async () => {
208 process.env["FORGE_SNAPSHOT_DIR"] = store;
209 try {
210 for (const n of ["bulk-one", "bulk-two"]) (await makeForge(n, false)).child.close();
211 const confirm = await fetch(`${servers.url}/forges/archive?name=bulk-one&name=bulk-two&name=../etc&name=nope`, { headers: { authorization: `Bearer ${token}` } });
212 const text = await confirm.text();
213 expect(confirm.status).toBe(200);
214 expect(text).toContain("bulk-one");
215 expect(text).toContain("bulk-two");
216 expect(text).not.toContain("etc");
217 expect((await fetch(`${servers.url}/forges/archive?name=bulk-one`)).status).toBe(404);
218 const out = await post("/forges/archive", [["name", "bulk-one"], ["name", "bulk-two"], ["name", "../x"]]);
219 expect(out.status).toBe(303);
220 expect(await childNamed(parent, "bulk-one")).toBeUndefined();
221 expect(await childNamed(parent, "bulk-two")).toBeUndefined();
222 expect(archivedForges(parent).map((a) => a.name).sort()).toEqual(expect.arrayContaining(["bulk-one", "bulk-two"]));
223 const restored = await post("/forges/bulk-one/restore");
224 expect(restored.status).toBe(303);
225 expect(await childNamed(parent, "bulk-one")).toBeDefined();
226 // Without the admin's own origin the post is refused.
227 const cross = await fetch(`${servers.url}/forges/archive`, { method: "POST", headers: { origin: "https://evil.test", authorization: `Bearer ${token}` }, body: new URLSearchParams({ name: "bulk-one" }) });
228 expect(cross.status).not.toBe(303);
229 expect(await childNamed(parent, "bulk-one")).toBeDefined();
230 } finally {
231 delete process.env["FORGE_SNAPSHOT_DIR"];
232 }
233 });
234
235 test("the sort offers the quietest first, and a proposal reaches the needs-you line", async () => {
236 const { child } = await makeForge("old-unclaimed", false);
237 child.close();
238 const file = join(dir, "spawn", "old-unclaimed.json");
239 const ledger = JSON.parse(await Bun.file(file).text());
240 ledger.createdAt = new Date(Date.now() - 45 * day).toISOString();
241 await Bun.write(file, JSON.stringify(ledger));
242 const rows = await servedRows(parent);
243 const row = rows.find((r) => r.name === "old-unclaimed")!;
244 // Not asked: claimed is unknown, so not proposed on a guess.
245 expect(row.archive).toBeNull();
246 const quiet = await (await fetch(`${servers.url}/forges?sort=quiet`, { headers: { authorization: `Bearer ${token}` } })).text();
247 expect(quiet).toContain('aria-current="true">Quietest first');
248 });
249
250 test("a claimed forge's owner is mailed before it can be proposed, and only a sent mail counts", async () => {
251 const { child, childDir } = await makeForge("claimed-idle", true);
252 child.db.run("UPDATE user SET created_at = '2026-01-02T00:00:00.000Z'");
253 child.close();
254 const file = join(dir, "spawn", "claimed-idle.json");
255 const ledger = JSON.parse(await Bun.file(file).text());
256 ledger.createdAt = "2026-01-01T00:00:00.000Z";
257 await Bun.write(file, JSON.stringify(ledger));
258 useChildFetch(parent, async () => Response.json({ claimed: true }));
259 const rows = await clientRows(parent);
260 const row = rows.find((r) => r.name === "claimed-idle")!;
261 expect(row.claimed).toBe(true);
262 expect(row.claimedBy).toBe("maria");
263 expect(row.archive).toBeNull();
264 const told = await noticeOwners(parent, [row], Date.now());
265 expect(told).toEqual(["claimed-idle"]);
266 expect(mails.at(-1)).toMatchObject({ to: "[email protected]" });
267 expect(readNotices(parent)["claimed-idle"]).toBeDefined();
268 // Told once: not again.
269 expect(await noticeOwners(parent, [{ ...row, noticedAt: readNotices(parent)["claimed-idle"]! }], Date.now())).toEqual([]);
270 expect(childDir.length).toBeGreaterThan(0);
271 expect((await listChildren(parent)).some((c) => c.name === "claimed-idle")).toBe(true);
272 });
273
274 test("an unclaimed forge untouched for 30 days is proposed in the needs-you line, never archived by itself", async () => {
275 const { child } = await makeForge("long-quiet", false);
276 child.close();
277 const file = join(dir, "spawn", "long-quiet.json");
278 const ledger = JSON.parse(await Bun.file(file).text());
279 ledger.createdAt = new Date(Date.now() - 45 * day).toISOString();
280 await Bun.write(file, JSON.stringify(ledger));
281 useChildFetch(parent, async () => Response.json({ claimed: false }));
282 const rows = await servedRows(parent);
283 expect(rows.find((r) => r.name === "long-quiet")?.archive).toMatchObject({ recommended: "Archive", ownerTold: null });
284 const home = await fetch(`${servers.url}/`, { headers: { authorization: `Bearer ${token}` } });
285 const text = await home.text();
286 expect(text).toContain("long-quiet");
287 expect(text).toContain("proposed for archive");
288 expect(text).toContain("/forges/archive?name=long-quiet");
289 // Proposed only: it is still here.
290 expect(await childNamed(parent, "long-quiet")).toBeDefined();
291 });
292
293 // The review of #12266: a copy comes from a store less trusted than the host, two asks for one
294 // forge do not race, a forge is not stopped for a copy that cannot be kept, and a copy is bounded.
295 test("a restore refuses a copy that holds a link, however its names look", async () => {
296 const work = await mkdtemp(join(tmpdir(), "forge-archive-evil-"));
297 try {
298 const bodyId = "evilbody1";
299 await Bun.write(join(work, "evil-copy.json"), "{}");
300 await Bun.write(join(work, bodyId, "real.txt"), "x");
301 await Bun.spawn(["ln", "-s", "/etc", join(work, bodyId, "x")]).exited;
302 const made = Bun.spawn(["tar", "-czf", join(work, "c.tar.gz"), "-C", work, "evil-copy.json", bodyId]);
303 expect(await made.exited).toBe(0);
304 const body = new Uint8Array(await Bun.file(join(work, "c.tar.gz")).arrayBuffer());
305 const real = dirStore(store);
306 await real.put("forge-archives/evil-copy/1.tar.gz", body);
307 const { sha256 } = await import("../src/kernel/hash.ts");
308 await Bun.write(join(dir, "archive", "evil-copy.json"), JSON.stringify({ name: "evil-copy", bodyId, archivedAt: new Date().toISOString(), where: store, key: "forge-archives/evil-copy/1.tar.gz", bytes: body.length, sha256: sha256(body), for: null, apps: [] }));
309 const done = await restoreForge(parent, "evil-copy", { destination: { source: "dir", store: real } });
310 expect(done.ok).toBe(false);
311 expect(done.says).toContain("not a forge's archive");
312 expect(existsSync(childDirOf(parent, bodyId))).toBe(false);
313 expect(readdirSync(join(dir, "archive")).some((f) => f.startsWith("restoring-"))).toBe(false);
314 } finally {
315 await rm(join(dir, "archive", "evil-copy.json"), { force: true });
316 await rm(work, { recursive: true, force: true });
317 }
318 });
319
320 test("a second archive of the same forge while one runs is refused", async () => {
321 const { child } = await makeForge("twice-asked", false);
322 child.close();
323 const real = dirStore(store);
324 let release = (): void => {};
325 const gate = new Promise<void>((r) => (release = r));
326 const slow = { ...real, async put(key: string, body: Uint8Array | string) { await gate; return real.put(key, body); } };
327 const first = archiveForge(parent, "twice-asked", { destination: { source: "dir", store: slow } });
328 await Bun.sleep(20); // real-timer: lets the first archive reach its held put before the second is asked
329 const second = await archiveForge(parent, "twice-asked", { destination: { source: "dir", store: real } });
330 expect(second.ok).toBe(false);
331 expect(second.says).toContain("already");
332 release();
333 expect((await first).ok).toBe(true);
334 expect((await restoreForge(parent, "twice-asked", { destination: { source: "dir", store: real } })).ok).toBe(true);
335 });
336
337 test("a destination that does not answer is found before the forge is stopped; a copy over the cap is refused and says it wakes", async () => {
338 const { childDir, child } = await makeForge("not-stopped", false);
339 child.close();
340 const real = dirStore(store);
341 const down = { ...real, async put() { throw new Error("bucket down"); } };
342 const refused = await archiveForge(parent, "not-stopped", { destination: { source: "dir", store: down } });
343 expect(refused.ok).toBe(false);
344 expect(refused.says).toContain("running as before");
345 // A copy over the cap: refused with a sentence that says the forge wakes on its next request.
346 const big = await archiveForge(parent, "not-stopped", { destination: { source: "dir", store: real }, maxBytes: 100 });
347 expect(big.ok).toBe(false);
348 expect(big.says).toContain("one archive can hold");
349 expect(big.says).toContain("wakes on its next request");
350 expect(await childNamed(parent, "not-stopped")).toBeDefined();
351 expect(existsSync(childDir)).toBe(true);
352 expect(readdirSync(join(dir, "archive", "files")).filter((f) => f.startsWith("not-stopped"))).toEqual([]);
353 });
test/spawn-pipes.test.ts +1
@@ -19,6 +19,7 @@ const PIPING: Record<string, { sites: string[]; reason: string }> = {
1919 "src/apps/backup_door.ts": { sites: ["stdout+stderr"], reason: "both read to their end alongside the exit: stdout's last line is the proof, stderr the refusal" },
2020 "src/apps/contain.ts": { sites: ["stderr"], reason: "stderr awaited with the exit; stdout ignored" },
2121 "src/apps/process.ts": { sites: ["stdout+stderr", "stdout+stderr"], reason: "pump() reads stdout and stderr of the app until exit" },
22 "src/spawn/archive.ts": { sites: ["stdout+stderr"], reason: "tar() reads the listing from stdout and the failure text from stderr, then waits for exit" },
2223 "src/spawn/cell.ts": { sites: ["stdout+stderr"], reason: "drain() reads the child forge's stdout and stderr to its log until exit" },
2324 "src/chaos/faults.ts": { sites: ["stdout"], reason: "stdout is read until the kill closes it, then cancelled; stderr is ignored" },
2425 "src/crew/claude.ts": { sites: ["stdout+stderr", "stdout"], reason: "session pumps stdout and stderr; --version awaits stdout, stderr ignored" },
test/spawn-sites.test.ts +2
@@ -79,6 +79,8 @@ const SITES: Record<string, string> = {
7979 "bun (process.execPath) on the release entry file",
8080 "src/snapshot/boot.ts":
8181 "bun (process.execPath) on src/main.ts in the snapshot tree",
82 "src/spawn/archive.ts":
83 "tar with fixed flags on the forge's own data directory and an archive path it derived from a validated name",
8284 "src/spawn/cell.ts":
8385 "a child forge launched from a launch spec the parent built, wrapped by its cell",
8486 "src/tui/screens/external.ts":
test/tui-forges.test.ts +3 −3
@@ -188,16 +188,16 @@ describe("forges you sent in the terminal", () => {
188188 const up = { total: 3, running: 3, notRunning: [] };
189189 const down = { total: 2, running: 1, notRunning: [{ slug: "notes", why: "build failed" }] };
190190 expect(forgeLines([row({ running: up })])).toEqual([
191 ["maria", "asleep · for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · shared disk · no heartbeat yet"],
191 ["maria", "asleep · for Maria's bakery · Not claimed yet · 3 apps running · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"],
192192 ["", ` ${firstScreenLine([])}`],
193193 ]);
194194 expect(forgeLines([row({ claimed: true, running: down, for: null })])).toEqual([
195 ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no heartbeat yet"],
195 ["maria", "asleep · Claimed · 1 app running · notes: build failed · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"],
196196 ]);
197197 expect(forgeLines([row({ claimed: null })])[0]![1]).toContain("Unknown");
198198 expect(forgeLines([row({ made: false })])[0]![1]).toContain("Still being made");
199199 expect(forgeLines([row({ attention: "down", claimed: true, for: null })])).toEqual([
200 ["maria", "asleep · App down · Claimed · has not reported yet · small · shared · shared disk · no heartbeat yet"],
200 ["maria", "asleep · App down · Claimed · has not reported yet · small · shared · shared disk · no person has done anything here · disk measuring · no memory (asleep) · no credit lent · no heartbeat yet"],
201201 ]);
202202 expect(forgeLines([row({})])[0]![1]).not.toMatch(/Not reporting|App down|Unclaimed/);
203203 expect(forgeLines([row({ nextStep: { key: "tell", words: "no tell yet", lastTellAt: null } })])[0]![1]).toContain("no tell yet");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs