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

The client list says in plain words where each sent forge link stands: sent, opened, signed in, first tell #10978

Merged opened this

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

Merged

scout/forge:scout/10923-the-client-list-says-in-plain-words-wher → main 7 commits fork 1 thread · 1 unresolved

  • Merged. scout/10923-the-client-list-says-in-plain-words-wher was merged into main as 65f69f9.

Review

Sign in to review this change.
13 files changed +207 −13
docs/MCP.md +1 −1
@@ -112,7 +112,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
112112 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
113113 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
114114 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
115 | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd, size, use | 216 |
115 | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use | 231 |
116116 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
117117 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 347 |
118118 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
src/contract/openapi/routes/getApiV1Forge.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/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt, link? }; lastActivityAt and link ({ state, revokedAt }) are null or absent unless the parent's secret is sent", auth: false, role: "anonymous", refusals: { not_found: "404: this forge was not spawned" } };
3 export const route: Route = { method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt, link? }; lastActivityAt and link ({ state, revokedAt, usedAt }) are null or absent unless the parent's secret is sent", auth: false, role: "anonymous", refusals: { not_found: "404: this forge was not spawned" } };
src/http/mcp/tools/forges.ts +2 −2
@@ -3,9 +3,9 @@ import { good, bad, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 name: "forges",
6 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed, last activity, reachable, size, use",
6 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",
77 description:
8 "Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd, size, use.",
8 "Forges sent from here (admin): name, address, apps, claimed, stage, resend, spendTodayUsd, ceilingUsd, size, use.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 async run(ctx, user) {
src/spawn/claim.ts +3 −1
@@ -249,6 +249,8 @@ export type LinkState = "open" | "used" | "ended";
249249 export interface LinkInfo {
250250 readonly state: LinkState;
251251 readonly revokedAt: string | null;
252 /** When the link was spent (someone opened it and made an account), ISO; absent while unspent. */
253 readonly usedAt?: string | null;
252254 }
253255
254256 /** True while an ended link can still be brought back: revoked in the last minute, in date, unspent. */
@@ -276,7 +278,7 @@ const stateOf = (s: Stored, now: number): LinkState =>
276278 /** The claim link in this forge's own database; null when it has none. */
277279 export function linkOf(db: Database, now = Date.now()): LinkInfo | null {
278280 const found = readStored(db);
279 return found === undefined ? null : { state: stateOf(found.stored, now), revokedAt: found.stored.r ?? null };
281 return found === undefined ? null : { state: stateOf(found.stored, now), revokedAt: found.stored.r ?? null, usedAt: found.stored.u };
280282 }
281283
282284 /** Replace the stored link only if nobody changed it since it was read. */
src/spawn/clients.ts +52 −3
@@ -52,6 +52,8 @@ export interface ClientRow {
5252 readonly link: LinkInfo | null;
5353 /** Why this forge needs its owner now; null when it does not. */
5454 readonly attention: Attention | null;
55 /** Where the sent link stands, in four plain states; null while it is being made or the child did not answer. */
56 readonly stage: Stage | null;
5557 /** Dollars the child's crew spent today, from its last heartbeat; null when it has not said. */
5658 readonly spendTodayUsd: number | null;
5759 /** The child's forge cap in dollars; null when none or unsaid. */
@@ -144,7 +146,8 @@ function linkOfBody(v: unknown): LinkInfo | null {
144146 if (typeof v !== "object" || v === null) return null;
145147 const { state, revokedAt } = v as Record<string, unknown>;
146148 if (!LINK_STATES.includes(state as LinkState)) return null;
147 return { state: state as LinkState, revokedAt: typeof revokedAt === "string" && !Number.isNaN(Date.parse(revokedAt)) ? revokedAt : null };
149 const iso = (x: unknown): string | null => (typeof x === "string" && !Number.isNaN(Date.parse(x)) ? x : null);
150 return { state: state as LinkState, revokedAt: iso(revokedAt), usedAt: iso((v as Record<string, unknown>)["usedAt"]) };
148151 }
149152
150153 async function probe(app: App, child: Child): Promise<ForgeState | undefined> {
@@ -281,11 +284,57 @@ export function attentionOf(r: ClientRow, now: number): Attention | null {
281284 return null;
282285 }
283286
287 export type StageKey = "sent" | "opened" | "signedin" | "tell";
288
289 /** Where a sent link stands, the time that changed, and whether it is time to send it again. */
290 export interface Stage {
291 readonly key: StageKey;
292 /** The plain words, the same on the page, the terminal and the MCP answer. */
293 readonly words: string;
294 /** When it changed, ISO; null when the child did not say. */
295 readonly at: string | null;
296 /** True when the link has gone unopened for three days: the one action is to resend it. */
297 readonly resend: boolean;
298 }
299
300 export const STAGE_WORDS: Record<StageKey, string> = {
301 sent: "Sent",
302 opened: "Opened",
303 signedin: "Signed in",
304 tell: "Last tell",
305 };
306
307 export const STALE_WORDS = "Not opened in 3 days";
308
309 /**
310 * Sent, opened, signed in, told: read from what is already kept (the
311 * ledger's send time, the link's spent time, the claim, the last tell in the
312 * child's heartbeat). The heartbeat carries only the latest tell, so the last stage says "Last tell", never "First".
313 */
314 export function stageOf(r: Omit<ClientRow, "attention" | "stage">, now: number): Stage | null {
315 if (!r.made || r.claimed === null) return null;
316 const at = (...xs: (string | null | undefined)[]): string | null => xs.find((x) => typeof x === "string") ?? null;
317 const make = (key: StageKey, when: string | null, resend = false): Stage => ({
318 key,
319 words: resend ? STALE_WORDS : STAGE_WORDS[key],
320 at: when,
321 resend,
322 });
323 if (r.claimed) {
324 return r.nextStep.lastTellAt !== null
325 ? make("tell", r.nextStep.lastTellAt)
326 : make("signedin", at(r.link?.usedAt));
327 }
328 if (r.link?.state === "used") return make("opened", at(r.link.usedAt));
329 const sent = r.sentAt === null ? NaN : Date.parse(r.sentAt);
330 return make("sent", r.sentAt, !Number.isNaN(sent) && now - sent > UNCLAIMED_MS);
331 }
332
284333 /** Rows that need the owner first, by reason; then by last activity, newest first. */
285 export function orderRows(rows: readonly Omit<ClientRow, "attention">[], now: number): ClientRow[] {
334 export function orderRows(rows: readonly Omit<ClientRow, "attention" | "stage">[], now: number): ClientRow[] {
286335 const tier = (a: Attention | null) => (a === null ? ATTENTION_ORDER.length : ATTENTION_ORDER.indexOf(a));
287336 return rows
288 .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now) }))
337 .map((r) => ({ ...r, attention: attentionOf(r as ClientRow, now), stage: stageOf(r as ClientRow, now) }))
289338 .sort(
290339 (a, b) =>
291340 tier(a.attention) - tier(b.attention) ||
src/tui/screens/forges.ts +1 −1
@@ -24,7 +24,7 @@ export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
2424 if (rows.length === 0) return [["", "You have not sent a forge yet"]];
2525 return rows.flatMap((r): [string, string][] => [[
2626 r.name,
27 [r.made ? (r.awake === true ? "awake" : "asleep") : 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.nextStep.words, spendWords(r)?.words ?? null, creditWords(r), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
27 [r.made ? (r.awake === true ? "awake" : "asleep") : 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), useLine(r.size, r.use), r.made ? diskWords(r.disk) : null, r.lastHeartbeatAt === null ? "no heartbeat yet" : `heartbeat ${timeAgo(r.lastHeartbeatAt)}`]
2828 .filter((m): m is string => m !== null)
2929 .join(" · "),
3030 ], ...addressLines(r)]);
src/web/pages/forges.ts +4 −2
@@ -55,7 +55,9 @@ export function forgeRow(r: ClientRow, now: number): Html {
5555 runningNote(r),
5656 spendNote(r),
5757 creditWords(r) === null ? null : html`<span data-credit>${creditWords(r)}</span>`,
58 r.sentAt === null ? null : html`sent ${time(r.sentAt)}`,
58 r.stage === null
outdated

Principle 1/dead code: the r.stage == null fallback keeps the old sent-time branch alive; make stage required and render only the stage.

59 ? null
60 : 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>`,
5961 r.lastActivityAt === null
6062 ? null
6163 : html`last activity ${time(r.lastActivityAt)}`,
@@ -88,7 +90,7 @@ export function forgeRow(r: ClientRow, now: number): Html {
8890 ...(state === "yes" ? { tone: "success" as const } : { variant: "outline" as const }),
8991 })}${state === "no"
9092 ? html`<form method="post" action="/forges/${r.name}/claim-link">
91 ${btn("Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })}
93 ${btn(r.stage?.resend === true ? "Resend" : "Send a fresh link", { size: "sm", title: "A new claim link; the old one stops working" })}
9294 </form>`
9395 : null}${r.link === null
9496 ? null
test/client-states.test.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * Where each sent forge link stands (#10923): sent, opened, signed in,
3 * first tell, in the same words on the page and in the MCP answer, read
4 * from what is already kept. A link unopened for three days says so and
5 * offers one action, resend.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp } from "../src/app.ts";
13 import { forgeLines } from "../src/tui/screens/forges.ts";
14 import { clientRows, orderRows, STAGE_WORDS, useChildFetch, STALE_WORDS, stageOf, type ClientRow } from "../src/spawn/clients.ts";
15 import { forgeRow } from "../src/web/pages/forges.ts";
16 import { tool } from "../src/http/mcp/tools/forges.ts";
17
18 const NOW = Date.parse("2026-11-10T12:00:00Z");
19 const DAY = 86_400_000;
20 const ago = (ms: number) => new Date(NOW - ms).toISOString();
21
22 const base = (over: Partial<Omit<ClientRow, "attention" | "stage">> = {}) =>
23 ({
24 name: "acme", url: "https://acme.example", for: "Maria", apps: [], sentAt: ago(DAY), made: true, claimed: false,
25 lastActivityAt: null, running: null, reach: null, link: { state: "open", revokedAt: null, usedAt: null }, spendTodayUsd: null,
26 ceilingUsd: null, size: "small", use: { kind: "shared" }, disk: { own: false }, lastHeartbeatAt: null,
27 nextStep: { key: "claim", words: "not claimed yet", lastTellAt: null }, ...over,
28 }) as unknown as Omit<ClientRow, "attention" | "stage">;
29
30 const stage = (over = {}) => orderRows([base(over)], NOW)[0]!;
31
32 describe("client states", () => {
33 test("sent, with the time it was sent", () => {
34 const s = stage().stage!;
35 expect([s.key, s.words, s.at, s.resend]).toEqual(["sent", "Sent", ago(DAY), false]);
36 });
37
38 test("opened once the link is spent but no one is signed in", () => {
39 const s = stage({ link: { state: "used", revokedAt: null, usedAt: ago(2 * DAY) } }).stage!;
40 expect([s.key, s.at]).toEqual(["opened", ago(2 * DAY)]);
41 });
42
43 test("signed in when claimed, at the time the link was spent; no time invented without it", () => {
44 const s = stage({ claimed: true, link: { state: "used", revokedAt: null, usedAt: ago(DAY / 2) } }).stage!;
45 expect([s.key, s.words, s.at]).toEqual(["signedin", "Signed in", ago(DAY / 2)]);
46 const bare = stage({ claimed: true, lastActivityAt: ago(60_000), link: { state: "used", revokedAt: null, usedAt: null } }).stage!;
47 expect([bare.key, bare.at]).toEqual(["signedin", null]);
48 });
49
50 test("last tell once the child's heartbeat carries a tell", () => {
51 const s = stage({ claimed: true, nextStep: { key: "going", words: "", lastTellAt: ago(3600_000) } }).stage!;
52 expect([s.key, s.words, s.at]).toEqual(["tell", "Last tell", ago(3600_000)]);
53 });
54
55 test("no state while it is made or when the child did not answer", () => {
56 expect(stage({ made: false }).stage).toBeNull();
57 expect(stage({ claimed: null }).stage).toBeNull();
58 });
59
60 test("unopened for three days is marked, and only then", () => {
61 expect(stageOf(base({ sentAt: ago(2 * DAY) }), NOW)!.resend).toBe(false);
62 const s = stageOf(base({ sentAt: ago(4 * DAY) }), NOW)!;
63 expect([s.key, s.words, s.resend]).toEqual(["sent", STALE_WORDS, true]);
64 expect(stageOf(base({ sentAt: ago(4 * DAY), claimed: true }), NOW)!.resend).toBe(false);
65 });
66
67 test("the page says the same words and offers resend only when stale", () => {
68 const fresh = String(forgeRow(stage(), NOW));
69 expect(fresh).toContain(`>${STAGE_WORDS.sent} `);
70 expect(fresh).not.toContain("Resend");
71 const stale = String(forgeRow(stage({ sentAt: ago(4 * DAY) }), NOW));
72 expect(stale).toContain(STALE_WORDS);
73 expect(stale).toContain("Resend");
74 expect(stale).toContain("/forges/acme/claim-link");
75 });
76
77 test("the terminal line carries the same words, and resend", () => {
78 const fresh = stage();
79 expect(forgeLines([fresh])[0]![1]).toContain(` ${fresh.stage!.words} `);
80 const stale = stage({ sentAt: ago(4 * DAY) });
81 expect(stale.stage!.resend).toBe(true);
82 expect(forgeLines([stale])[0]![1]).toContain(STALE_WORDS);
83 });
84
85 test("the forges tool answers with the page's words and resend", async () => {
86 const dir = mkdtempSync(join(tmpdir(), "forge-stage-"));
87 const app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
88 try {
89 mkdirSync(join(dir, "spawn"), { recursive: true });
90 writeFileSync(
91 join(dir, "spawn", "kid.json"),
92 JSON.stringify({ name: "kid", bodyId: "cell-k", done: true, placement: "hosted", size: "small", apps: [], createdAt: new Date(Date.now() - 4 * DAY).toISOString() }),
93 );
94 useChildFetch(app, async () => Response.json({ claimed: false, link: { state: "open", revokedAt: null, usedAt: null } }));
95 const [row] = await clientRows(app);
96 const out = (await tool.run({ app } as never, { isAdmin: true } as never, {})) as { ok: true; value: any };
97 const got = (out.value.forges ?? out.value.value?.forges ?? JSON.parse(JSON.stringify(out)).forges ?? [])[0];
98 expect(got.stage).toEqual({ ...row!.stage!, at: got.stage.at });
99 expect(got.stage.words).toBe(STALE_WORDS);
100 expect(got.stage.resend).toBe(true);
101 expect(String(forgeRow(row!, Date.now()))).toContain(got.stage.words);
102 expect(tool.doc).toContain("reachable");
103 expect(tool.description).toContain("resend");
104 } finally {
105 app.close();
106 }
107 });
108 });
test/disk-mount.test.ts +1
@@ -166,6 +166,7 @@ describe("the child's line", () => {
166166 url: "https://kid--forge.test",
167167 for: null,
168168 apps: [],
169 stage: null,
169170 sentAt: null,
170171 made: true,
171172 claimed: true,
test/forges-attention.test.ts +2 −1
@@ -10,11 +10,12 @@ import { ATTENTION_WORDS, orderRows, type ClientRow } from "../src/spawn/clients
1010 const NOW = Date.parse("2026-10-25T12:00:00Z");
1111 const ago = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
1212
13 const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attention"> => ({
13 const row = (name: string, o: Partial<ClientRow> = {}): Omit<ClientRow, "attention" | "stage"> => ({
1414 name,
1515 url: `https://${name}.test`,
1616 for: null,
1717 apps: [],
18 stage: null,
1819 sentAt: ago(1),
1920 made: true,
2021 claimed: true,
test/security-floor-doors.test.ts +1 −1
@@ -92,7 +92,7 @@ test("each return of the public door and of `answered` is floored", () => {
9292 // `response` is only ever what `answered` gave back.
9393 for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/);
9494 // `answered` floors what the work returns, and its failure path floors too.
95 expect(region).toContain("const done = await compress(request, response).then(securityFloor)");
95 expect(region).toMatch(/const done = await compress\(request, response\)\.then\(\(r\) => securityFloor\(/);
9696 expect(server).toMatch(/function failure\([^]*?\n}\n/);
9797 const failure = server.match(/function failure\([^]*?\n}\n/)![0];
9898 for (const r of failure.matchAll(/\breturn\s+([^\n]+)/g)) expect(r[1]!.trim()).toMatch(/^securityFloor\(/);
test/tui-forges.test.ts +1
@@ -20,6 +20,7 @@ const row = (over: Partial<ClientRow>): ClientRow => ({
2020 url: "https://maria.test",
2121 for: "Maria's bakery",
2222 apps: [],
23 stage: null,
2324 sentAt: null,
2425 made: true,
2526 claimed: false,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs