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

/boards: forges made, time to live, and failures #2950

Merged opened this

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

Merged

scout/forge:scout/2932-boards-forges-made-time-to-live-and-fail → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2932-boards-forges-made-time-to-live-and-fail was merged into main as 209a6d3.

Review

Sign in to review this change.
15 files changed +471 −13
README.md +1 −1
@@ -1555,7 +1555,7 @@ work here.
15551555 `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit
15561556 by the admin who made it, and a file that does not validate is not shown. Make, rename,
15571557 duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`.
1558 The first boot also writes six starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security** and **Throughput**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks.
1558 The first boot also writes seven starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security**, **Throughput** and **Forges**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks.
15591559 A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap
15601560 (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug`
15611561 (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added.
docs/APPS.md +13
@@ -1201,6 +1201,19 @@ leaves its series absent, never zero, and `/boards/data?source=machine`
12011201 names each one. Its rows belong to no repo, so only an every-repo scope
12021202 reads them; the sources list carries each column's unit in `docs`.
12031203
1204 The `spawns` and `spawnTime` sources (admin only) read the one-click path's
1205 events: `forge.spawn.started`, `forge.spawn.live` (`ms` since started, when the
1206 child's `/healthz` first answers; `spawn/live.ts`) and `forge.spawn.failed`
1207 (`step`: place, copy or health). A payload `drill: 1` makes it the `drill`
1208 series, so the nightly drill never mixes into a person's numbers. The Forges
1209 starting board shows forges made per day, p50/p95 time to live against the
1210 3-minute budget, and failures by step. The watcher that waits for `/healthz`
1211 lives in the process: a restart mid-spawn leaves a `started` with no outcome,
1212 and that spawn is simply never counted live or failed. A retry of an
1213 unfinished child appends its own `started`, so every `failed` has one. The
1214 watchers poll every second, then every five after 30 seconds, stop when the
1215 forge closes, and at most 20 run at once; a spawn past that is made, not timed.
1216
12041217 The `stages` source counts items moving from issue to live: one row per
12051218 item entering a stage, with `stage` one of `1 issue` (opened), `2 run` (a
12061219 builder first started on the issue; a retry is not a second item), `3 pull`
src/actions/spawn.ts +13 −2
@@ -94,6 +94,8 @@ export interface SpawnDeps {
9494 readonly modelKey?: string;
9595 /** Anthropic-compatible API base URL sealed with the starter key (OpenRouter, etc.). */
9696 readonly modelEndpoint?: string;
97 /** The nightly drill's own forge: its spawn events carry `drill`, so the boards keep it as its own series. */
98 readonly drill?: boolean;
9799 }
98100
99101 interface Ledger {
@@ -123,6 +125,12 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/;
123125 export const STARTER_KEY = "starter";
124126 const inFlight = new Set<string>();
125127
128 /** A failure the boards count: the step that broke (a fixed few), never its words. */
129 function failed(app: App, deps: SpawnDeps, step: "place" | "copy" | "health", message: string) {
130 app.bus.append({ kind: "forge.spawn.failed", payload: { step, ...(deps.drill === true ? { drill: 1 } : {}) } });
131 return fail(message);
132 }
133
126134 const fail = (message: string) => err<SpawnError>({ kind: "failed", message });
127135
128136 export const childDirOf = (app: App, bodyId: string): string =>
@@ -228,12 +236,14 @@ async function make(
228236 ledger.beatKey ??= secret();
229237 await writeLedger(app, ledger);
230238 }
239 // A retry of an unfinished child is a new attempt: its failure has a start.
240 app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } });
231241 } else {
232242 const placed = await deps.placement.place(plan);
233243 if (!placed.ok) return err({ kind: "refused", ...placed.error });
234244 if (!SAFE_ID.test(placed.value.bodyId)) {
235245 await deps.placement.retire(placed.value.bodyId);
236 return fail("placement returned an unusable body id");
246 return failed(app, deps, "place", "placement returned an unusable body id");
237247 }
238248 ledger = {
239249 name: plan.name,
@@ -248,10 +258,11 @@ async function make(
248258 beatKey: secret(),
249259 };
250260 await writeLedger(app, ledger);
261 app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } });
251262 }
252263
253264 const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint);
254 if (!copied.ok) return fail(copied.error);
265 if (!copied.ok) return failed(app, deps, "copy", copied.error);
255266 const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value);
256267
257268 ledger.done = true;
src/app.ts +5
@@ -204,6 +204,8 @@ export interface App {
204204 readonly hookAddress: { current: string };
205205 /** Per-boot secret every internal request must carry (`FORGE_HOOK_SECRET` in a hook's env). */
206206 readonly hookSecret: string;
207 /** Aborted by `close()`: background pollers stop on it and write nothing after. */
208 readonly closing: AbortController;
207209 /** What `vitals` turns into needs: this forge's own, not the process's. */
208210 readonly signals: { errors: ErrorLedger; refused: RefusedLedger };
209211 close(): void;
@@ -228,6 +230,7 @@ export function createApp(config: ForgeConfig): App {
228230 // Idempotent — a forge without them changes nothing.
229231 dropLegacyNewAccountGuards(db);
230232 const bus = new EventBus(db);
233 const closing = new AbortController();
231234
232235 // Every event durably enqueues webhook deliveries; the worker (main.ts)
233236 // sends them. Listener throws are swallowed by the bus, so this can never
@@ -251,7 +254,9 @@ export function createApp(config: ForgeConfig): App {
251254 socketPath,
252255 hookAddress: { current: socketPath },
253256 hookSecret: randomBytes(32).toString("hex"),
257 closing,
254258 close: () => {
259 closing.abort();
255260 // The pooled `cat-file` readers for this forge's repos are processes,
256261 // and processes outlive a closed database unless somebody says
257262 // otherwise. One data dir is one forge, so closing the forge is where
src/boards/sources.ts +55
@@ -43,6 +43,23 @@ const percentile = (name: string, column: string, p: number) =>
4343 `COUNT(*) OVER (PARTITION BY route, day) AS n FROM page_vital WHERE ${column} IS NOT NULL) ` +
4444 `WHERE rn = (n * ${p} + 99) / 100`;
4545
46 /** The budget a spawned forge has to answer its `/healthz`: three minutes (#2914). */
47 export const SPAWN_BUDGET_MS = 180_000;
48
49 /** A spawn event's payload fields, as a drill or a person's forge, read in place of the `event` table. */
50 const SPAWN_SERIES = "CASE WHEN json_extract(payload, '$.drill') = 1 THEN 'drill' ELSE 'forge' END";
51 const SPAWN_KINDS = "kind IN ('forge.spawn.started', 'forge.spawn.live', 'forge.spawn.failed')";
52 const SPAWN_DAY = "substr(created_at, 1, 10) || 'T00:00:00.000Z'";
53
54 /** Nearest-rank percentile `p` of the ms to live, per series per day, as rows of the `spawnTime` source. */
55 const spawnPercentile = (name: string, p: number) =>
56 `SELECT '${name}' AS metric, series, at, ms AS value FROM (` +
57 `SELECT ${SPAWN_SERIES} AS series, ${SPAWN_DAY} AS at, json_extract(payload, '$.ms') AS ms, ` +
58 `ROW_NUMBER() OVER (PARTITION BY ${SPAWN_SERIES}, ${SPAWN_DAY} ORDER BY json_extract(payload, '$.ms')) AS rn, ` +
59 `COUNT(*) OVER (PARTITION BY ${SPAWN_SERIES}, ${SPAWN_DAY}) AS n ` +
60 `FROM event WHERE kind = 'forge.spawn.live' AND json_extract(payload, '$.ms') IS NOT NULL) ` +
61 `WHERE rn = (n * ${p} + 99) / 100`;
62
4663 export const SOURCES: Readonly<Record<string, Source>> = {
4764 runs: {
4865 table: "run",
@@ -232,6 +249,44 @@ export const SOURCES: Readonly<Record<string, Source>> = {
232249 value: "the count, or the percentile (nearest rank) of that route that day",
233250 },
234251 },
252 // Forges made by the one-click path (#2914): one row per spawn event
253 // (`actions/spawn.ts`, `spawn/live.ts`). The forge's name and the failure's
254 // words are not listed; the step is one of a fixed few.
255 spawns: {
256 table: "spawn",
257 owner: "admin",
258 select:
259 `SELECT substr(kind, 13) AS outcome, ${SPAWN_SERIES} AS series, json_extract(payload, '$.step') AS step, ` +
260 `json_extract(payload, '$.ms') AS ms, created_at AS at FROM event WHERE ${SPAWN_KINDS}`,
261 time: "at",
262 columns: { outcome: "text", series: "text", step: "text", ms: "number", at: "time" },
263 docs: {
264 outcome: "started, live (its /healthz first answered) or failed",
265 series: "forge (a person's) or drill (the nightly drill's own forge)",
266 step: "failed only: place, copy or health",
267 ms: "live only: milliseconds from started to the first /healthz answer",
268 },
269 },
270 // Time to live per day: p50 and p95 of the spawns that went live, and the
271 // budget as a third row so a line chart shows the margin.
272 spawnTime: {
273 table: "spawn_time",
274 owner: "admin",
275 select: [
276 spawnPercentile("p50", 50),
277 spawnPercentile("p95", 95),
278 `SELECT 'budget', series, at, ${SPAWN_BUDGET_MS} FROM (SELECT DISTINCT ${SPAWN_SERIES} AS series, ${SPAWN_DAY} AS at ` +
279 `FROM event WHERE ${SPAWN_KINDS})`,
280 ].join(" UNION ALL "),
281 time: "at",
282 columns: { metric: "text", series: "text", at: "time", value: "number" },
283 docs: {
284 metric: "p50 or p95 (nearest rank) of the ms to live that day, or budget",
285 series: "forge (a person's) or drill (the nightly drill's own forge)",
286 at: "the day, UTC",
287 value: "milliseconds; the budget is 180000 (three minutes)",
288 },
289 },
235290 // Items entering each stage from issue to live, one row per entry, read
236291 // from issue, run and pull as `flow/throughput.ts` reads them: an issue
237292 // opens, a builder first starts on it (a retry is not a second item), its
src/boards/starting.ts +21 −1
@@ -1,5 +1,5 @@
11 /**
2 * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security and Throughput. They are plain
2 * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput and Forges. They are plain
33 * boards in `forge/boards` once written, so a person edits or deletes them
44 * like any other; `.offered` (a JSON list of slugs, committed with the
55 * boards) records what was offered so a deletion sticks and an edit is
@@ -17,6 +17,13 @@ const panel = (title: string, chart: Panel["chart"], query: Record<string, unkno
1717 });
1818 const MINUTE = { bucket: "minute" };
1919 const HOUR = { bucket: "hour" };
20 const DAY = { bucket: "day" };
21 const SPAWNS = { source: "spawns", aggregate: "count" };
22 const started = { column: "outcome", op: "eq", value: "started" };
23 const timeToLive = (series: string) => ({
24 source: "spawnTime", aggregate: "max", column: "value", groupBy: DAY, series: "metric",
25 filter: [{ column: "series", op: "eq", value: series }],
26 });
2027 const STAGES = { source: "stages", aggregate: "count" };
2128 const machine = (op: "eq" | "starts", value: string, column: string) => ({
2229 source: "machine", aggregate: "avg", column, groupBy: MINUTE, filter: [{ column: "target", op, value }],
@@ -105,6 +112,19 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
105112 panel("Stages by hour", "heat", { ...STAGES, groupBy: HOUR, series: "stage" }, 2),
106113 ],
107114 },
115 forges: {
116 title: "Forges",
117 range: "30d",
118 refresh: "1m",
119 panels: [
120 panel("Forges made per day", "bar", { ...SPAWNS, groupBy: DAY, series: "series", filter: [started] }, 2),
121 panel("Forges live per day", "bar", { ...SPAWNS, groupBy: DAY, series: "series", filter: [{ column: "outcome", op: "eq", value: "live" }] }, 2),
122 panel("Time to live (ms): p50, p95, budget 3 min", "line", timeToLive("forge"), 2),
123 panel("The drill's time to live (ms): p50, p95, budget 3 min", "line", timeToLive("drill"), 2),
124 panel("Failures by step", "bar", { ...SPAWNS, groupBy: "step", series: "series", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, 2),
125 panel("Failures per day", "bar", { ...SPAWNS, groupBy: DAY, series: "step", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, 2),
126 ],
127 },
108128 };
109129
110130 /** The slugs already offered, from the repo; none when it has no such file yet. */
src/spawn/door.ts +12 −1
@@ -6,6 +6,7 @@
66 * the caller says `false` — returns the plan and changes nothing.
77 */
88
9 import { startWatch, probeOf, type Probe } from "./live.ts";
910 import { listChildren, reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts";
1011 import type { App } from "../app.ts";
1112 import { maxChildren } from "../core/capacity.ts";
@@ -37,6 +38,12 @@ export interface Door {
3738
3839 const MAX_CREW = 100;
3940 const placements = new WeakMap<App, Placement>();
41 const probes = new WeakMap<App, (url: string) => Probe>();
42
43 /** Tests swap how a new child is asked whether it is live. */
44 export function useProbe(app: App, probe: (url: string) => Probe): void {
45 probes.set(app, probe);
46 }
4047
4148 /** Tests (and an external placement, one day) swap where children live. */
4249 export function usePlacement(app: App, placement: Placement): void {
@@ -264,6 +271,7 @@ export async function spawnDoor(
264271 },
265272 };
266273 }
274 const startedAt = Date.now();
267275 const made = await spawnForge(ctx.app, user, input, source, {
268276 placement: placementFor(ctx.app),
269277 request: ctx.request,
@@ -274,7 +282,10 @@ export async function spawnDoor(
274282 if (!made.ok) return spawnWords(made.error);
275283 if (anon && !made.value.existed) noteAnonSpawn(ctx.request);
276284 // Its apps come up now, so the first visit after a claim finds them awake.
277 if (!made.value.existed) void bootHostedChild(ctx.app, made.value.name);
285 if (!made.value.existed) {
286 void bootHostedChild(ctx.app, made.value.name);
287 startWatch(ctx.app, { name: made.value.name, startedAt, probe: (probes.get(ctx.app) ?? probeOf)(made.value.url) });
outdated

This is void watchLive(...) with no handle. It polls for up to 10 minutes, and nothing cancels it when the app closes. app.bus.append on a closed db then throws inside an unawaited promise, which is an unhandled rejection. Scout says the existing spawn-api tests now hit the real probe, so this already happens in the suite. Fix: keep the watchers in a per-app set, stop them on app.close(), and have the door tests use useProbe so no test touches the network.

288 }
278289 return {
279290 status: made.value.existed ? 200 : 201,
280291 body: {
src/spawn/live.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * Time to live (#2932): the moment a spawned child's `/health` first
3 * answers, recorded on the outbox as `forge.spawn.live` with the ms since
4 * the spawn began, or `forge.spawn.failed` at step `health` when it never
5 * does. The boards read both (`boards/sources.ts`). One watcher per child,
6 * polling its public address; the probe is injected so a test needs no network.
7 * The watcher lives in this process: a restart mid-spawn leaves a `started`
8 * with no `live` or `failed`, and the boards show that gap as unfinished.
9 */
10 import type { App } from "../app.ts";
11
12 export const POLL_MS = 1000;
13 /** After this long without an answer, poll every `SLOW_POLL_MS` instead. */
14 export const SLOW_AFTER_MS = 30_000;
15 export const SLOW_POLL_MS = 5000;
16 /** At most this many watchers at once per forge; a spawn past it is made but not timed. */
17 export const MAX_WATCHERS = 20;
18 const open = new WeakMap<App, number>();
19 /** Give up after this long: well past the 3-minute budget, so a slow forge still counts as live, late. */
20 export const GIVE_UP_MS = 10 * 60_000;
21
22 export type Probe = () => Promise<boolean>;
23
24 /** A probe of `<url>/healthz`: true on a 200, false on anything else. Never throws. */
25 export function probeOf(url: string): Probe {
26 return async () => {
27 try {
28 const res = await fetch(`${url.replace(/\/+$/, "")}/healthz`, {
29 signal: AbortSignal.timeout(2000),
30 redirect: "manual",
31 });
32 await res.body?.cancel();
33 return res.status === 200;
34 } catch {
35 return false;
36 }
37 };
38 }
39
40 export interface Watch {
41 readonly name: string;
42 /** When the spawn began, ms since the epoch. */
43 readonly startedAt: number;
44 readonly probe: Probe;
45 readonly drill?: boolean;
46 readonly pollMs?: number;
47 readonly giveUpMs?: number;
48 readonly now?: () => number;
49 }
50
51 /** Start a watcher unless this forge already runs `MAX_WATCHERS`; true when started. */
52 export function startWatch(app: App, w: Watch): boolean {
outdated

The loop has no abort signal and no cap on concurrent watchers. Each new spawn, anonymous ones included, adds a poller that does a fetch every second for 10 minutes. Take an AbortSignal (or check a closed flag) in the loop. Back off the poll interval, for example 1s then 5s after 30s. Cap the open watchers. Add a test that closing the app stops the watcher without appending an event.

53 const n = open.get(app) ?? 0;
54 if (n >= MAX_WATCHERS) return false;
55 open.set(app, n + 1);
56 void watchLive(app, w).finally(() => open.set(app, (open.get(app) ?? 1) - 1));
57 return true;
58 }
59
60 /**
61 * Poll until the child answers or time runs out; appends exactly one event,
62 * or none when the forge closes first. Never throws.
63 */
64 export async function watchLive(app: App, w: Watch): Promise<"live" | "failed" | "stopped"> {
65 const now = w.now ?? Date.now;
66 const poll = w.pollMs ?? POLL_MS;
67 const until = w.startedAt + (w.giveUpMs ?? GIVE_UP_MS);
68 const drill = w.drill === true ? { drill: 1 } : {};
69 const sleep = (ms: number) =>
70 new Promise<void>((done) => {
71 const t = setTimeout(done, ms);
72 app.closing.signal.addEventListener("abort", () => { clearTimeout(t); done(); }, { once: true });
73 });
74 while (true) {
75 if (app.closing.signal.aborted) return "stopped";
76 let up = false;
77 try {
78 up = await w.probe();
79 } catch {
80 up = false;
81 }
82 if (app.closing.signal.aborted) return "stopped";
83 if (up) {
84 const ms = Math.max(0, Math.round(now() - w.startedAt));
85 app.bus.append({ kind: "forge.spawn.live", payload: { ms, ...drill } });
86 return "live";
87 }
88 const t = now();
89 if (t >= until) break;
90 await sleep(t - w.startedAt >= SLOW_AFTER_MS ? Math.max(poll, SLOW_POLL_MS) : poll);
91 }
92 app.bus.append({ kind: "forge.spawn.failed", payload: { step: "health", ...drill } });
93 return "failed";
94 }
test/boards-starting.test.ts +4 −4
@@ -1,5 +1,5 @@
11 /**
2 * The forge offers the six starting boards on boot: every panel runs, an
2 * The forge offers the seven starting boards on boot: every panel runs, an
33 * edit survives the next boot, and a deletion is not undone.
44 */
55 import { afterAll, beforeAll, expect, test } from "bun:test";
@@ -36,10 +36,10 @@ test("a forge with no person to found the repo writes nothing", async () => {
3636 expect(await offerStartingBoards(bare)).toEqual([]);
3737 });
3838
39 test("a fresh boot writes the six starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]);
39 test("a fresh boot writes the seven starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]);
4141 const boards = await listBoards(app.reposRoot);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]);
4343 let panels = 0;
4444 for (const { board } of boards) {
4545 for (const p of board.panels) {
test/new-forge.test.ts +3 −1
@@ -16,7 +16,7 @@ import { createUser } from "../src/core/users.ts";
1616 import { commitFiles } from "../src/git/write.ts";
1717 import { repoDir } from "../src/git/repos.ts";
1818 import { serve, type Servers } from "../src/http/server.ts";
19 import { usePlacement } from "../src/spawn/door.ts";
19 import { usePlacement, useProbe } from "../src/spawn/door.ts";
2020 import { childrenForMemory, maxChildren, setCapacity } from "../src/core/capacity.ts";
2121 import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts";
2222 import { quietLogs } from "./helpers/globals.ts";
@@ -302,6 +302,7 @@ describe("+ New forge", () => {
302302 });
303303
304304 test("a forge at its depth limit shows no door, and its page says why", async () => {
305 useProbe(app, () => async () => true);
305306 usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 }));
306307 expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"');
307308 const html = await (await get(admin, "/forges/new")).text();
@@ -312,6 +313,7 @@ describe("+ New forge", () => {
312313 test("at the room limit the door stays, disabled, with the sentence; raising the setting reopens it", async () => {
313314 const store = memoryBodies();
314315 store.add({ bodyId: "b1", name: "one", url: "https://one--forge.test", depth: 1, agents: 1, ssh: false });
316 useProbe(app, () => async () => true);
315317 usePlacement(app, hostedPlacement({ host: "forge.test", store, maxChildren: () => maxChildren(app.db) }));
316318 setCapacity(app.db, "children.max", "1", "system");
317319 const sentence = "1 of 1 forges sent on this node — retire one, or raise the room in settings";
test/spawn-anon.test.ts +2 −1
@@ -10,7 +10,7 @@ import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { usePlacement } from "../src/spawn/door.ts";
13 import { usePlacement, useProbe } from "../src/spawn/door.ts";
1414 import { resetAnonSpawns } from "../src/spawn/rate.ts";
1515 import { createToken } from "../src/core/tokens.ts";
1616 import { createUser } from "../src/core/users.ts";
@@ -69,6 +69,7 @@ describe("anonymous spawn", () => {
6969 });
7070 const priv = await createRepo(app, a.value, { name: "diary", visibility: "private" });
7171 if (!priv.ok) throw new Error("private repo");
72 useProbe(app, () => async () => true);
7273 usePlacement(app, {
7374 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
7475 retire: async () => ok(),
test/spawn-api.test.ts +2 −1
@@ -10,7 +10,7 @@ import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { createAgent } from "../src/actions/crew.ts";
1212 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { spawnWords, usePlacement } from "../src/spawn/door.ts";
13 import { spawnWords, usePlacement, useProbe } from "../src/spawn/door.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { createUser } from "../src/core/users.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
@@ -61,6 +61,7 @@ describe("spawn over the API and MCP", () => {
6161 await commitToBranch(app, a.value, repo.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] });
6262 const agent = await createAgent(app, a.value, { username: "scout", brief: "ship small" });
6363 if (!agent.ok) throw new Error(`agent ${JSON.stringify(agent.error)}`);
64 useProbe(app, () => async () => true);
6465 usePlacement(app, {
6566 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }),
6667 retire: async () => ok(),
test/spawn-events.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The events spawn emits (#2932), driven through `spawnForge` and the door
3 * with a fake placement: removing an append in either fails a test here.
4 */
5 import { afterEach, beforeEach, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { spawnForge, type SpawnDeps } from "../src/actions/spawn.ts";
11 import { createApp, type App } from "../src/app.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { repoDir } from "../src/git/repos.ts";
14 import { commitFiles } from "../src/git/write.ts";
15 import { ok } from "../src/kernel/result.ts";
16 import { spawnDoor, usePlacement, useProbe } from "../src/spawn/door.ts";
17 import type { Placement } from "../src/spawn/placement.ts";
18 import type { SpawnSource } from "../src/spawn/plan.ts";
19 import type { Ctx } from "../src/http/context.ts";
20
21 let dir: string;
22 let app: App;
23 let alice: User;
24 let source: SpawnSource;
25 let retired: string[];
26 let bodyId: string;
27 const input = { name: "kid", org: "kids", apps: ["alice/notes"], carryBriefs: false, carrySubscriptions: false };
28
29 const placement = (): Placement => ({
30 place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId }),
31 retire: async (id) => { retired.push(id); return ok(); },
32 });
33 const deps = (extra: Partial<SpawnDeps> = {}): SpawnDeps => ({
34 placement: placement(),
35 request: new Request("https://forge.test/spawn", { method: "POST", headers: { origin: "https://forge.test" } }),
36 url: new URL("https://forge.test/spawn"),
37 ...extra,
38 });
39 const events = () =>
40 app.db.query("SELECT kind, payload FROM event WHERE kind LIKE 'forge.spawn.%' ORDER BY seq").all()
41 .map((r) => ({ kind: (r as any).kind as string, payload: JSON.parse((r as any).payload) }));
42
43 beforeEach(async () => {
44 dir = await mkdtemp(join(tmpdir(), "forge-spawn-events-"));
45 app = createApp({ dataDir: dir, url: "https://forge.test" });
46 retired = [];
47 bodyId = "body-1";
48 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
49 if (!made.ok) throw new Error("user");
50 alice = made.value;
51 const repo = await createRepo(app, alice, { name: "notes" });
52 if (!repo.ok) throw new Error("repo");
53 const c = await commitFiles(repoDir(app.reposRoot, "alice", "notes"), {
54 branch: "main", message: "one", files: [{ path: "a.txt", content: "a" }], author: { name: "a", email: "[email protected]" },
55 });
56 if (!c.ok) throw new Error("commit");
57 source = { catalog: [{ ref: "alice/notes", readable: true, bytes: 10, forgeJsonBytes: 0 }], briefs: [], subscriptions: [] };
58 });
59 afterEach(async () => {
60 app.close();
61 await rm(dir, { recursive: true, force: true });
62 });
63
64 test("a good spawn appends exactly one started, and a retry of it none", async () => {
65 expect((await spawnForge(app, alice, input, source, deps())).ok).toBe(true);
66 expect(events()).toEqual([{ kind: "forge.spawn.started", payload: {} }]);
67 const again = await spawnForge(app, alice, input, source, deps());
68 expect(again.ok && again.value.existed).toBe(true);
69 expect(events().length).toBe(1);
70 });
71
72 test("a retry after a failed copy starts again, so each failed has a started", async () => {
73 const gone = repoDir(app.reposRoot, "alice", "notes");
74 await Bun.$`mv ${gone} ${gone}.away`.quiet();
75 await spawnForge(app, alice, input, source, deps());
76 await spawnForge(app, alice, input, source, deps());
77 expect(events().map((e) => e.kind)).toEqual([
78 "forge.spawn.started", "forge.spawn.failed", "forge.spawn.started", "forge.spawn.failed",
79 ]);
80 await Bun.$`mv ${gone}.away ${gone}`.quiet();
81 expect((await spawnForge(app, alice, input, source, deps())).ok).toBe(true);
82 expect(events().map((e) => e.kind).slice(4)).toEqual(["forge.spawn.started"]);
83 });
84
85 test("an unsafe body id fails at place and retires the body", async () => {
86 bodyId = "../escape";
87 const r = await spawnForge(app, alice, input, source, deps());
88 expect(r.ok).toBe(false);
89 expect(retired).toEqual(["../escape"]);
90 expect(events()).toEqual([{ kind: "forge.spawn.failed", payload: { step: "place" } }]);
91 });
92
93 test("a copy that breaks fails at copy, after started, with no words of the failure", async () => {
94 await rm(repoDir(app.reposRoot, "alice", "notes"), { recursive: true, force: true });
95 const r = await spawnForge(app, alice, input, source, deps());
96 expect(r.ok).toBe(false);
97 expect(events()).toEqual([
98 { kind: "forge.spawn.started", payload: {} },
99 { kind: "forge.spawn.failed", payload: { step: "copy" } },
100 ]);
101 });
102
103 test("deps.drill marks started and failed as the drill", async () => {
104 bodyId = "../escape";
105 await spawnForge(app, alice, input, source, deps({ drill: true }));
106 expect(events()).toEqual([{ kind: "forge.spawn.failed", payload: { step: "place", drill: 1 } }]);
107 bodyId = "body-2";
108 await spawnForge(app, alice, { ...input, name: "kid2", org: "kids2" }, source, deps({ drill: true }));
109 expect(events()[1]).toEqual({ kind: "forge.spawn.started", payload: { drill: 1 } });
110 });
111
112 test("the door starts a watcher for a new child and not for an existing one", async () => {
113 usePlacement(app, placement());
114 const probed: string[] = [];
115 useProbe(app, (url) => async () => { probed.push(url); return true; });
116 const ctx = { app, request: deps().request, url: deps().url } as unknown as Ctx;
117 const raw = { name: "kid", org: "kids", apps: ["alice/notes"], dryRun: false };
118 const live = () => events().filter((e) => e.kind === "forge.spawn.live").length;
119 const until = async (n: number) => { for (let i = 0; i < 100 && live() < n; i++) await Bun.sleep(20); };
120
121 expect((await spawnDoor(ctx, alice, raw)).status).toBe(201);
122 await until(1);
123 expect(live()).toBe(1);
124 expect(probed.length).toBe(1);
125
126 expect((await spawnDoor(ctx, alice, raw)).status).toBe(200);
127 await Bun.sleep(100);
128 expect(probed.length).toBe(1);
129 expect(live()).toBe(1);
130 });
test/spawn-live.test.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * Forges made, time to live and failures (#2932): the spawn events land on
3 * the outbox, and the Forges board reads them, the drill as its own series.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp } from "../src/app.ts";
10 import { runQuery } from "../src/boards/query.ts";
11 import { STARTING_BOARDS } from "../src/boards/starting.ts";
12 import { MAX_WATCHERS, startWatch, watchLive } from "../src/spawn/live.ts";
13
14 let dir: string;
15 let app: ReturnType<typeof createApp>;
16 const all = { repoIds: "all" } as const;
17 const kinds = () => app.db.query("SELECT kind FROM event WHERE kind LIKE 'forge.spawn.%' ORDER BY seq").all().map((r) => (r as { kind: string }).kind);
18
19 beforeAll(async () => {
20 dir = await mkdtemp(join(tmpdir(), "forge-spawn-live-"));
21 app = createApp({ dataDir: dir, port: 0 });
22 });
23 afterAll(async () => {
24 await rm(dir, { recursive: true, force: true });
25 });
26
27 test("a child that answers is live, with the ms since the spawn began", async () => {
28 let calls = 0;
29 let t = 1000;
30 const got = await watchLive(app, {
31 name: "x", startedAt: 1000, pollMs: 1, now: () => (t += 500),
32 probe: async () => ++calls === 3,
33 });
34 expect(got).toBe("live");
35 const row = app.db.query("SELECT payload FROM event WHERE kind = 'forge.spawn.live'").get() as { payload: string };
36 expect(JSON.parse(row.payload)).toEqual({ ms: 1500 });
37 });
38
39 test("a child that never answers fails at step health, once", async () => {
40 let t = 0;
41 const got = await watchLive(app, { name: "y", startedAt: 0, pollMs: 1, giveUpMs: 1000, now: () => (t += 400), probe: async () => false, drill: true });
42 expect(got).toBe("failed");
43 const row = app.db.query("SELECT payload FROM event WHERE kind = 'forge.spawn.failed'").get() as { payload: string };
44 expect(JSON.parse(row.payload)).toEqual({ step: "health", drill: 1 });
45 });
46
47 test("a probe that throws counts as not yet", async () => {
48 let calls = 0;
49 const got = await watchLive(app, { name: "z", startedAt: Date.now(), pollMs: 1, probe: async () => { if (++calls < 2) throw new Error("boom"); return true; } });
50 expect(got).toBe("live");
51 });
52
53 test("a day with only failures still has a budget line", async () => {
54 const fresh = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-fail-")), port: 0 });
55 fresh.bus.append({ kind: "forge.spawn.failed", payload: { step: "copy" } });
56 const r = runQuery(fresh.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric" }, all);
57 expect(r).toEqual({ ok: true, rows: [{ group: "budget", value: 180000 }] });
58 fresh.close();
59 });
60
61 test("the spawns source separates the drill, and the time source gives p50, p95 and the budget", () => {
62 for (const [ms, drill] of [[60000, 0], [90000, 0], [120000, 0], [150000, 0], [30000, 1]] as const)
63 app.bus.append({ kind: "forge.spawn.live", payload: { ms, ...(drill ? { drill: 1 } : {}) } });
64 app.bus.append({ kind: "forge.spawn.started", payload: {} });
65 const made = runQuery(app.db, { source: "spawns", aggregate: "count", groupBy: "series", filter: [{ column: "outcome", op: "eq", value: "live" }] }, all);
66 expect(made).toEqual({ ok: true, rows: [{ group: "drill", value: 1 }, { group: "forge", value: 6 }] });
67 const failures = runQuery(app.db, { source: "spawns", aggregate: "count", groupBy: "step", series: "series", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, all);
68 expect(failures).toEqual({ ok: true, rows: [{ group: "health", value: 1, series: "drill" }] });
69 const time = runQuery(app.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric", filter: [{ column: "series", op: "eq", value: "drill" }] }, all);
70 expect(time.ok && time.rows.map((r) => r.group)).toEqual(["budget", "p50", "p95"]);
71 const forge = runQuery(app.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric", filter: [{ column: "series", op: "eq", value: "forge" }] }, all);
72 expect(forge.ok && forge.rows.map((r) => r.value)).toEqual([180000, expect.any(Number), expect.any(Number)]);
73 expect(kinds()).toEqual([
74 "forge.spawn.live", "forge.spawn.failed", "forge.spawn.live",
75 "forge.spawn.live", "forge.spawn.live", "forge.spawn.live", "forge.spawn.live", "forge.spawn.live",
76 "forge.spawn.started",
77 ]);
78 });
79
80 test("only a scope of every repo reads the spawn sources", () => {
81 expect(runQuery(app.db, { source: "spawns", aggregate: "count" }, { repoIds: ["r"] })).toEqual({ ok: true, rows: [] });
82 });
83
84 test("the Forges board is offered, with a panel for made, time to live and failures", () => {
85 const b = STARTING_BOARDS["forges"]!;
86 expect(b.panels.map((p) => p.title).join("|")).toMatch(/made.*Time to live.*Failures by step/);
87 for (const p of b.panels) expect(runQuery(app.db, p.query, all).ok).toBe(true);
88 });
89
90 test("closing the app stops a watcher, and it appends nothing", async () => {
91 const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-close-")), port: 0 });
92 const pending = watchLive(mine, { name: "c", startedAt: Date.now(), pollMs: 10_000, probe: async () => false });
93 await Bun.sleep(20);
94 mine.close();
95 expect(await pending).toBe("stopped");
96 });
97
98 test("at most MAX_WATCHERS run at once per forge", async () => {
99 const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-cap-")), port: 0 });
100 const w = { name: "c", startedAt: Date.now(), pollMs: 10_000, probe: async () => false };
101 const started = Array.from({ length: MAX_WATCHERS + 3 }, () => startWatch(mine, w));
102 expect(started.filter(Boolean).length).toBe(MAX_WATCHERS);
103 mine.close();
104 });
105
106 test("after 30 seconds the poll slows to five", async () => {
107 const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-slow-")), port: 0 });
108 let calls = 0;
109 const pending = watchLive(mine, { name: "s", startedAt: 0, pollMs: 5, now: () => 31_000, giveUpMs: 10 ** 9, probe: async () => { calls++; return false; } });
110 await Bun.sleep(100);
111 mine.close();
112 await pending;
113 expect(calls).toBe(1);
114 });
test/tui-newforge.test.ts +2 −1
@@ -9,7 +9,7 @@ import { join } from "node:path";
99 import { createRepo } from "../src/actions/repos.ts";
1010 import { createApp, type App } from "../src/app.ts";
1111 import { createUser, type User } from "../src/core/users.ts";
12 import { usePlacement } from "../src/spawn/door.ts";
12 import { usePlacement, useProbe } from "../src/spawn/door.ts";
1313 import { hostedPlacement } from "../src/spawn/placement.ts";
1414 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1515 import { newForgeScreen } from "../src/tui/screens/newforge.ts";
@@ -57,6 +57,7 @@ beforeAll(async () => {
5757 bob = await mk("bob", false);
5858 const repo = await createRepo(app, admin, { name: "bakery", visibility: "public" });
5959 if (!repo.ok) throw new Error("repo");
60 useProbe(app, () => async () => true);
6061 usePlacement(app, hostedPlacement({ host: "forge.test" }));
6162 });
6263 afterAll(async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs