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

The forge's failure domains as data: src/kernel/domains.ts and docs/DOMAINS.md, each domain's words quoted from what the forge already says #1859

Merged opened this

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

Merged

scout/forge:scout/1850-the-forge-s-failure-domains-as-data-src → main 1 commit fork

  • Merged. scout/1850-the-forge-s-failure-domains-as-data-src was merged into main as 6e05f8e.

Review

Sign in to review this change.
4 files changed +207 −0
docs/DOMAINS.md added +19
@@ -0,0 +1,19 @@
1 # Failure domains
2
3 The seven ways the forge stops, and what it says when each does.
4 `src/kernel/domains.ts` is the data; `test/domains.test.ts` keeps this table
5 equal to it and proves each quoted phrase is what the forge's own code emits
6 under that condition. The words are quoted from `kernel/vitals.ts`, never
7 drafted. "Nothing yet" means the forge has no words for that domain today.
8 Minutes back are estimates, not measurements. A drill named here is the name
9 issue #1195 will build; only the restore drill exists today.
10
11 | Domain | What breaks | The forge says | Minutes back | The owner | Drill |
12 | --- | --- | --- | --- | --- | --- |
13 | Compute (the pod) | Nothing answers until the pod is rescheduled. | nothing yet | 2 | Wait for the platform to restart it; read the pod's events if it does not. | kill-pod |
14 | State (the volume) | Writes fail once the volume fills; the data is lost with the volume. | "Disk is nearly full:" | 60 | Free or grow the volume; if it is gone, restore the newest snapshot (docs/RECOVERY.md). | none: Filling or detaching the live volume would lose writes. |
15 | The snapshot store | Backups stop landing or cannot be read back; nothing live is affected. | "The restore drill failed at" | 30 | Fix the bucket or its keys, then run the restore drill again. | restore-drill |
16 | The edge (tunnel and DNS) | The forge runs but nobody outside can reach it. | nothing yet | 10 | Restart the tunnel; check the DNS record. | none: Cutting the tunnel cuts every person and agent off the forge. |
17 | Identity | Sign-in fails; sessions and tokens already issued may still work. | nothing yet | 15 | Restore the sign-in provider's settings; sign in with the admin password. | none: Breaking sign-in locks the owner out of the forge that would fix it. |
18 | The model credential | Agents cannot start work; everything else runs. | nothing yet | 5 | Set a fresh credential in the settings. | expire-token |
19 | The runner | Checks and runs queue behind a lane that is not moving. | "wait behind one lane; the oldest since"; "is stuck: no heartbeat for" | 10 | Cancel the stuck run; restart the runner if the lane stays empty. | lose-runner |
scripts/gate.ts +1
@@ -165,6 +165,7 @@ export const GATE: readonly string[] = [
165165 "test/migrate.test.ts",
166166 "test/schema-additive.test.ts",
167167 "test/schema-migrations.test.ts",
168 "test/domains.test.ts",
168169 "test/vitals.test.ts",
169170 "test/vitals-degraded.test.ts",
170171 "test/red-main.test.ts",
src/kernel/domains.ts added +95
@@ -0,0 +1,95 @@
1 /**
2 * The forge's failure domains as data: what breaks, the words the forge
3 * already says when it does (`says`: fragments copied from what
4 * `kernel/vitals.ts` emits, each proved by `test/domains.test.ts`), the
5 * minutes until it is back (estimates, not measurements), what the owner
6 * does, and the drill that exercises it. A domain the forge has no words for
7 * yet has an empty `says`; `docs/DOMAINS.md` is the same table for a person.
8 */
9
10 export interface Domain {
11 readonly key: string;
12 readonly name: string;
13 readonly breaks: string;
14 /** Fragments of sentences the forge emits today; empty when it says nothing yet. */
15 readonly says: readonly string[];
16 readonly backMinutes: number;
17 readonly owner: string;
18 /** A drill's name, or why it cannot be drilled without harm. */
19 readonly drill: { readonly name: string } | { readonly name: null; readonly why: string };
20 }
21
22 export const DOMAINS: readonly Domain[] = [
23 {
24 key: "compute",
25 name: "Compute (the pod)",
26 breaks: "Nothing answers until the pod is rescheduled.",
27 says: [],
28 backMinutes: 2,
29 owner: "Wait for the platform to restart it; read the pod's events if it does not.",
30 drill: { name: "kill-pod" },
31 },
32 {
33 key: "state",
34 name: "State (the volume)",
35 breaks: "Writes fail once the volume fills; the data is lost with the volume.",
36 says: ["Disk is nearly full:"],
37 backMinutes: 60,
38 owner: "Free or grow the volume; if it is gone, restore the newest snapshot (docs/RECOVERY.md).",
39 drill: { name: null, why: "Filling or detaching the live volume would lose writes." },
40 },
41 {
42 key: "snapshots",
43 name: "The snapshot store",
44 breaks: "Backups stop landing or cannot be read back; nothing live is affected.",
45 says: ["The restore drill failed at"],
46 backMinutes: 30,
47 owner: "Fix the bucket or its keys, then run the restore drill again.",
48 drill: { name: "restore-drill" },
49 },
50 {
51 key: "edge",
52 name: "The edge (tunnel and DNS)",
53 breaks: "The forge runs but nobody outside can reach it.",
54 says: [],
55 backMinutes: 10,
56 owner: "Restart the tunnel; check the DNS record.",
57 drill: { name: null, why: "Cutting the tunnel cuts every person and agent off the forge." },
58 },
59 {
60 key: "identity",
61 name: "Identity",
62 breaks: "Sign-in fails; sessions and tokens already issued may still work.",
63 says: [],
64 backMinutes: 15,
65 owner: "Restore the sign-in provider's settings; sign in with the admin password.",
66 drill: { name: null, why: "Breaking sign-in locks the owner out of the forge that would fix it." },
67 },
68 {
69 key: "credential",
70 name: "The model credential",
71 breaks: "Agents cannot start work; everything else runs.",
72 says: [],
73 backMinutes: 5,
74 owner: "Set a fresh credential in the settings.",
75 drill: { name: "expire-token" },
76 },
77 {
78 key: "runner",
79 name: "The runner",
80 breaks: "Checks and runs queue behind a lane that is not moving.",
81 says: ["wait behind one lane; the oldest since", "is stuck: no heartbeat for"],
82 backMinutes: 10,
83 owner: "Cancel the stuck run; restart the runner if the lane stays empty.",
84 drill: { name: "lose-runner" },
85 },
86 ];
87
88 const cell = (s: string): string => s.replace(/\|/g, "\\|");
89
90 /** One domain as its row in `docs/DOMAINS.md`. */
91 export function domainRow(d: Domain): string {
92 const says = d.says.length === 0 ? "nothing yet" : d.says.map((s) => `"${s}"`).join("; ");
93 const drill = d.drill.name === null ? `none: ${d.drill.why}` : d.drill.name;
94 return `| ${[d.name, d.breaks, says, String(d.backMinutes), d.owner, drill].map(cell).join(" | ")} |`;
95 }
test/domains.test.ts added +92
@@ -0,0 +1,92 @@
1 /** The failure-domain map: the doc and the data agree, and the forge really says what the data quotes. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { readFileSync } from "node:fs";
5 import { mkdtemp, rm } from "node:fs/promises";
6 import { tmpdir } from "node:os";
7 import { join } from "node:path";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createRun } from "../src/core/runs.ts";
11 import { DOMAINS, domainRow } from "../src/kernel/domains.ts";
12 import { vitals } from "../src/kernel/vitals.ts";
13 import { recordVerdict } from "../src/snapshot/verdict.ts";
14
15 let dataDir: string;
16 let app: App;
17 let repoId = "";
18
19 beforeAll(async () => {
20 dataDir = await mkdtemp(join(tmpdir(), "forge-domains-"));
21 app = createApp({ dataDir, port: 0 });
22 const owner = await createUser(app.db, {
23 username: "alice", email: "[email protected]", password: "a-good-passphrase",
24 });
25 if (!owner.ok) throw new Error("no owner");
26 repoId = "repo-domains";
27 app.db.query("INSERT INTO repo (id, owner_id, name) VALUES (?1, ?2, 'r')").run(repoId, owner.value.id);
28 });
29 afterAll(async () => {
30 await rm(dataDir, { recursive: true, force: true });
31 });
32
33 const said = (key: string): string[] => {
34 const d = DOMAINS.find((x) => x.key === key)!;
35 expect(d.says.length).toBeGreaterThan(0);
36 return [...d.says];
37 };
38 const read = (now = Date.now()) =>
39 vitals({ db: app.db, signals: app.signals, dataDir, now }).needs.join("\n");
40
41 test("seven domains, each complete", () => {
42 expect(DOMAINS.map((d) => d.key)).toEqual([
43 "compute", "state", "snapshots", "edge", "identity", "credential", "runner",
44 ]);
45 for (const d of DOMAINS) {
46 expect(d.breaks && d.owner && d.name).toBeTruthy();
47 expect(d.backMinutes).toBeGreaterThan(0);
48 if (d.drill.name === null) expect(d.drill.why.trim()).not.toBe("");
49 }
50 });
51
52 test("the doc's table is the data", () => {
53 const doc = readFileSync(join(import.meta.dir, "..", "docs", "DOMAINS.md"), "utf8");
54 const rows = doc.split("\n").filter((l) => l.startsWith("| ") && !l.startsWith("| Domain") && !l.startsWith("| ---"));
55 expect(rows).toEqual(DOMAINS.map(domainRow));
56 });
57
58 test("a nearly full disk says the state's words", () => {
59 const v = vitals({ db: app.db, signals: app.signals, dataDir, diskFreeMin: 1.1 });
60 for (const s of said("state")) expect(v.needs.join("\n")).toContain(s);
61 });
62
63 test("a red restore drill says the snapshot store's words", () => {
64 recordVerdict(dataDir, {
65 at: new Date().toISOString(), snapshotId: null, ms: 1, counts: { repositories: 0, issues: 0 },
66 health: "unreached", verdict: "red", failedStep: "fetch", reason: "no bucket",
67 });
68 for (const s of said("snapshots")) expect(read()).toContain(s);
69 });
70
71 test("a stalled check queue and a silent run say the runner's words", () => {
72 app.db.query("DELETE FROM run").run();
73 const now = Date.now();
74 const iso = (ms: number) => new Date(ms).toISOString();
75 const live = createRun(app.db, { repoId, kind: "check" });
76 app.db
77 .query("UPDATE run SET state='running', started_at=?2, heartbeat_at=?3 WHERE id=?1")
78 .run(live.id, iso(now - 31 * 60_000), iso(now - 4 * 60_000));
79 for (let i = 0; i < 2; i++) {
80 const waiting = createRun(app.db, { repoId, kind: "check" });
81 app.db.query("UPDATE run SET created_at=?2 WHERE id=?1").run(waiting.id, iso(now - 13 * 60_000));
82 }
83 const text = read(now);
84 for (const s of said("runner")) expect(text).toContain(s);
85 app.db.query("DELETE FROM run").run();
86 });
87
88 test("a domain with no words says nothing, and the doc says so", () => {
89 const silent = DOMAINS.filter((d) => d.says.length === 0).map((d) => d.key);
90 expect(silent).toEqual(["compute", "edge", "identity", "credential"]);
91 expect(readFileSync(join(import.meta.dir, "..", "docs", "DOMAINS.md"), "utf8")).toContain("nothing yet");
92 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs