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

Everything the forge needs to be reachable and to release is written down, with where it runs: today six of those things run on the DigitalOcean node #11692

Merged opened this in answer to

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

Merged

torque/forge:torque/11685-everything-the-forge-needs-to-be-reachab → main 7 commits fork

  • Merged. torque/11685-everything-the-forge-needs-to-be-reachab was merged into main as 7ceb97c.

Review

Sign in to review this change.
16 files changed +1198 −5
AGENTS.md +2
@@ -25,6 +25,8 @@ State shared between modules through process memory, not the database, is named
2525
2626 `src/core/transfer.ts`: the old owner/name pairs a transfer left behind (`repo_redirect`), held per database after the first request reads them and dropped by `transferRepo`, so a request under a path nothing moved from costs no statement (#569).
2727
28 `src/system/footing.ts`: `footing`, the last reading of what the forge stands on from the kube API (#11685), held 60 s so /system, its terminal screen (`src/tui/screens/footing.ts`, palette entry for admins) and the MCP `system` tool (`kind: footing`) share one read; a probe that throws is not retried for 60 s; one reading, gone on restart.
29
2830 `src/apps/timer_fire.ts`: `inFlight`, the ids of timers being fired in this process, so a slow app's timer is never fired twice at once; at most the timers due, gone when each settles. The timers themselves are rows (`timer`), read at every boot, never memory.
2931
3032 ## Awake ceiling and the boot
docs/MCP.md +1 −1
@@ -209,7 +209,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
209209 | `subscription_wake` | — | Admin: wake a resting subscription | 168 |
210210 | `subscriptions` | — | Claude accounts: scopes, budgets | 158 |
211211 | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 |
212 | `system` | `csp`, `routes` | The forge machine now: CPU, memory, disk, network | 286 |
212 | `system` | `csp`, `routes`, `footing` | The forge machine now: CPU, memory, disk, network | 296 |
213213 | `take_back` | — | Open the pull that undoes a shipped pull | 219 |
214214 | `take_updates` | — | Take the parent forge's updates into a fork | 176 |
215215 | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 |
docs/OPERATING.md +63
@@ -89,6 +89,69 @@ Unset, only this machine may say so, and the named header is never read. Everyth
8989 instance _is_ lives under the data directory — the SQLite index, the bare
9090 repos, and the SSH host key, in plain layout. Tar it up and it moves.
9191
92 ## What the forge stands on
93
94 Each thing the forge needs to answer a request, sign a person in, build,
95 release and back up, the machine it runs on, what happens to the forge if
96 that machine dies, and the issue that removes the dependency. The list is the
97 steward's read-only inventory of both machines on 2026-11-12 (#11685), the day
98 pg-grand took the forge off the internet for 24 minutes while the forge itself
99 ran fine on the vxrail, and the day the owner decided to drop pg-grand
100 (#11686). Every row on pg-grand is a way that machine takes the forge
101 down.
102
103 <!-- footing:start — written by footingTable() in src/system/footing.ts; test/footing.test.ts holds it equal -->
104 - **pg-grand**: DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.
105 - **vxrail**: A k3s agent joined to pg-grand. The forge runs here.
106
107 | Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by |
108 |---|---|---|---|---|---|
109 | (a) answer a request | The forge's pod (git.open-platform.sh/trevato-apps/forge:&lt;version&gt;) and its data, PVC forge-data-vxrail (60 Gi) | vxrail | The forge is down, and its data is on that disk. | — | — |
110 | (a) answer a request | Cloudflare tunnel carrying forge.open-platform.sh (standby/cloudflared), moved to the vxrail by hand on 2026-11-12; its last-applied manifest still says 0 replicas, so a re-render moves it back | vxrail | It goes with the forge, which runs beside it. | — | #8948, #11686 |
111 | (a) answer a request | The vxrail's own tunnel (standby/cloudflared-forge-home, 2 replicas): it too sends *.open-platform.sh to Traefik; only forge-home-test.open-platform.sh and arm--forge-trevato-apps.open-platform.sh go straight to the forge | vxrail | It goes with the forge, which runs beside it. | — | — |
112 | (a) answer a request | Traefik (kube-system/traefik): every public request goes tunnel → Traefik → the forge's Service | pg-grand | The forge is off the internet; it keeps running inside. | 1 | #11686, #8946 |
113 | (a) answer a request | Child and app hosts, Traefik IngressRoutes forge-children (&lt;name&gt;--forge.open-platform.sh), forge-apps (*--forge-trevato-apps.open-platform.sh) and forge-old-host, and game.cheffryjames.com and www.cheffryjames.com in the forge's namespace | pg-grand | No child forge, app or custom domain answers. | 1 | #11686, #8946 |
114 | (a) answer a request | forge-ssh, a host socket proxy (:2222 → the forge's ssh service) | pg-grand | git over ssh and the terminal stop answering. | 7 | #11686 |
115 | (a) answer a request | The k3s API server (the vxrail is an agent joined to it) | pg-grand | Running pods keep running, but nothing reschedules, Services stop updating, and no release rolls or Job runs. | 5 | #11686 |
116 | (b) sign a person in | Sign-in edge: a forwardAuth middleware on the forge's Ingress, answered by mcp/plat-auth | pg-grand | Every request through the edge answers 500, /healthz too. | 2 | #11686 |
117 | (b) sign a person in | forge-signup-gate, the pod /signup is routed to | pg-grand | No one can sign up. | 3 | #11686 |
118 | (c) build | Forgejo (git.open-platform.sh) and its Actions, which build the release image | pg-grand | No release image is built. | 4 | #11686, #8941 |
119 | (c) build | Forgejo's database, a single instance under CloudNativePG (cnpg-system) | pg-grand | Forgejo stops, and with it builds. | 4 | #11686, #8941 |
120 | (c) build | forgejo-runner-vxrail, a Forgejo Actions runner | vxrail | It goes with the forge, which runs beside it. | — | — |
121 | (d) release | Forgejo's registry (git.open-platform.sh), which the forge's image is pulled from | pg-grand | A pod that restarts on a node without the image cached cannot start; nothing new is released. | 4 | #11686, #8941, #8942 |
122 | (d) release | registry-node, a registry on the vxrail | vxrail | It goes with the forge, which runs beside it. | — | — |
123 | (d) release | forge-release-tick, a host timer every 5 min | pg-grand | Nothing is released; production keeps the release it has. | 7 | #11686, #8942 |
124 | (d) release | standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended | pg-grand | A changed manifest is not applied; the running pod is untouched. | 7 | #8948, #11686 |
125 | (d) release | Host timers forge-watchdog, forge-github-mirror and forge-prune-images | pg-grand | The watchdog, the GitHub mirror and image pruning stop. | 7 | #11686 |
126 | (e) back up | The backup namespace's CronJobs (FORGE_BACKUP_LANES) | pg-grand | No new backup is taken. | 6 | #11686 |
127 | (e) back up | MinIO, where the backup lanes store the forge's backups | pg-grand | No new backup is stored, and those it holds go with the machine. | 6 | #11686 |
128
129 If pg-grand were switched off now, the forge breaks in this order (the Breaks column):
130
131 1. Traefik: nothing public reaches the forge
132 2. plat-auth: no one signs in
133 3. the signup gate: no one signs up
134 4. image pulls and releases
135 5. the control plane: nothing reschedules, Services stop updating
136 6. local backups
137 7. the host timers
138
139 Also on pg-grand, not in the forge's path: Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO.
140 <!-- footing:end -->
141
142 The same list is read from the live cluster on `/system`, under "What the
143 forge stands on", on the terminal's screen of that name, and from the MCP
144 `system` tool with `{ kind: "footing" }`; how to read each is in
145 [docs/features/footing.md](features/footing.md). The probe
146 (`src/system/footing.ts`) asks the kube API, with the forge's own service
147 account, for the forge's node, the nodes labelled control-plane, and the pods
148 in each namespace above; it never lists every pod in the cluster. What the
149 cluster cannot show keeps its declared machine and says why. To see every
150 row from the cluster, the forge's service account needs `get` on its own pod,
151 `list` on `nodes`, and `list` on `pods` in those namespaces (#8948). The
152 reading is held 60 s, a page waits at most 0.8 s for it, and a probe that
153 fails is not retried for a minute.
154
92155 ## Health
93156
94157 `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with
docs/features/footing.md added +58
@@ -0,0 +1,58 @@
1 # What the forge stands on (#11685)
2
3 The pieces the forge needs to answer a request, sign a person in, build,
4 release and back up, and the machine each one runs on. Any piece that is not
5 on the forge's own machine is a way that machine can take the forge down. The
6 declared list is the table in [`docs/OPERATING.md`](../OPERATING.md#what-the-forge-stands-on);
7 the three doors below read it against the live cluster.
8
9 All three are for a forge admin who is a person (the MCP tool: a forge admin).
10 None of them waits on the cluster: they show the last reading, held 60 s, and
11 the declared list while the first one runs.
12
13 ## On /system
14
15 Under **What the forge stands on**, the summary says how many pieces run on
16 another machine. Open it to see:
17
18 - where the forge runs (its pod's node), or why that was not read;
19 - each piece on another machine, in the order switching pg-grand off would
20 break the forge, with what it is needed for, where it runs, what happens if
21 that machine stops, its place in that order, and the issues that remove it;
22 - the pieces on the forge's own machine, by name;
23 - what each machine is.
24
25 "Runs on" is a node name when the cluster showed the piece's pod there. It
26 reads `pg-grand (declared: …)` when the cluster could not show it, with the
27 reason: a systemd unit on a host, a CronJob with no pod between runs, a
28 namespace the service account may not list, or a pod whose namespace is not
29 recorded. A declared piece is judged by its declared machine.
30
31 ## In the terminal
32
33 Open the palette and choose **What the forge stands on** (it follows
34 **Main-thread stalls**). The screen lists the same rows as the page: the
35 forge's node, then each piece on another machine in break order with its
36 step, need, machine and what happens if it dies, then the steps themselves and
37 the pieces on the forge's own machine. `j`/`k` scroll, `esc` closes.
38
39 ## Over MCP
40
41 ```json
42 { "name": "system", "arguments": { "kind": "footing" } }
43 ```
44
45 answers `forgeNode`, `unread` (why the forge's node was not read, or null),
46 `at`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues,
47 breaks }`. `seen` is `{ by: "cluster", nodes }` when the cluster showed it and
48 `{ by: "declared", why }` otherwise; `risk` is true when the piece is not on
49 the forge's machine. `risks` repeats those pieces as one line each, in break
50 order. Anyone but a forge admin is refused.
51
52 ## Keeping it true
53
54 The list is `FOOTING` in `src/system/footing.ts`; `test/footing.test.ts` holds
55 the OPERATING.md table equal to `footingTable()`. Change the list there, then
56 paste the output of
57 `bun -e 'import {footingTable} from "./src/system/footing.ts"; console.log(footingTable())'`
58 between the markers in OPERATING.md.
src/http/mcp/tools/system.ts +10 −4
@@ -7,16 +7,17 @@ import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts";
77 import { lagMinutes, loopWatch, perfRecord } from "../../../perf/live.ts";
88 import { LAG_BAR_MS, overBar } from "../../../perf/lag_minutes.ts";
99 import { slowestRoutes } from "../../../perf/watch.ts";
10 import { footing, riskLines } from "../../../system/footing.ts";
1011 import { good, bad, type Tool } from "../shared.ts";
1112
1213 export const tool: Tool = {
1314 name: "system",
14 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only",
15 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only",
1516 description:
1617 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
17 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes"] } } },
18 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing"] } } },
1819 writes: false,
19 run(ctx, user, args) {
20 async run(ctx, user, args) {
2021 if (args["kind"] !== undefined) {
2122 if (args["kind"] === "routes") {
2223 const reader = telemetryReader(ctx.app, user);
@@ -24,7 +25,12 @@ export const tool: Tool = {
2425 noteRead(ctx.app, user, reader, "system");
2526 return good({ routes: slowestRoutes(perfRecord, Date.now()) });
2627 }
27 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, or absent for the machine");
28 if (args["kind"] === "footing") {
29 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads what it stands on");
30 const f = await footing.read();
31 return good({ ...f, risks: riskLines(f) });
32 }
33 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, or absent for the machine");
2834 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations");
2935 const now = new Date();
3036 return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) });
src/perf/record.ts +6
@@ -50,6 +50,8 @@ export interface PerfRecord {
5050 /** When `pattern` was first seen in this process, undefined if never. */
5151 first(pattern: string): number | undefined;
5252 patterns(): string[];
53 /** Forget every route's samples (not the minute ledger), as `loopWatch.clear` does its stalls: for a test that reads the record whole. */
54 clear(): void;
5355 /** Who used the answering time, by the minute: exact however the samples are thinned. */
5456 readonly ledger: TrafficLedger;
5557 }
@@ -100,6 +102,10 @@ export function createPerfRecord(valid: (key: string) => boolean = validPattern)
100102 traffic: (pattern, since) => (real.get(pattern) ?? []).filter((s) => s.at >= since),
101103 first: (pattern) => firsts.get(pattern),
102104 patterns: () => [...real.keys()],
105 clear() {
106 real.clear();
107 firsts.clear();
108 },
103109 };
104110 }
105111
src/system/footing.ts added +524
@@ -0,0 +1,524 @@
1 /**
2 * What the forge stands on (#11685): every piece the forge needs to answer a
3 * request, sign a person in, build, release and back up, where each was
4 * found running, and what happens to the forge if that machine dies.
5 *
6 * `FOOTING` is the declared list, from the steward's read-only inventory of
7 * both machines on 2026-11-12, the day a full disk on pg-grand (DigitalOcean)
8 * took the forge off the internet for 24 minutes while the forge itself ran on
9 * the vxrail, and the owner decided to drop pg-grand. `docs/OPERATING.md` carries the same list as
10 * a table (`footingTable` writes it; `test/footing.test.ts` holds the doc to
11 * it), so the two cannot drift.
12 *
13 * `probeFooting` reads the live cluster over the kube API with the forge's
14 * own service account: the forge's node (its own pod's `spec.nodeName`), the
15 * control-plane nodes, and the node of each piece's pods. A piece the cluster
16 * cannot show (a systemd unit on a host, a CronJob with no pod now, a
17 * namespace the service account may not list) keeps its declared machine and
18 * says so. Any required piece not on the forge's own machine is a risk line.
19 * Only namespaced pod lists are asked for, never the whole cluster's.
20 *
21 * `footing` holds the last reading for 60 s in process memory (named in
22 * AGENTS.md), so /system, its terminal screen (`src/tui/screens/footing.ts`)
23 * and the MCP answer share one read and a page never waits on the cluster for
24 * more than `WAIT_MS`; a probe that throws is not retried for 60 s.
25 *
26 * The descriptions carry no figure that goes stale (disk fill, sizes, counts):
27 * those were true on one day and are not re-read here.
28 */
29 import { serviceAccount, type Credentials } from "../cluster/jobs.ts";
30
31 export type Need = "answer" | "sign-in" | "build" | "release" | "backup";
32
33 /** The machines in the forge's path, by the name the steward's inventory gives them. */
34 export type Machine = "pg-grand" | "vxrail";
35
36 /** The machine the forge itself runs on, as declared. */
37 export const FORGE_MACHINE: Machine = "vxrail";
38
39 /** What each machine is, from the steward's read-only inventory of 2026-11-12. */
40 export const MACHINES: Record<Machine, string> = {
41 "pg-grand": "DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.",
42 vxrail: "A k3s agent joined to pg-grand. The forge runs here.",
43 };
44
45 export type Find =
46 /** Pods of one workload (Deployment, StatefulSet, DaemonSet) in a namespace, or every pod (`*`); namespace `""` is the forge's own. */
47 | { readonly kind: "pods"; readonly namespace: string; readonly workload: string }
48 /** The forge's own pod. */
49 | { readonly kind: "self" }
50 /** The Kubernetes API server: the nodes labelled control-plane. */
51 | { readonly kind: "control-plane" }
52 /** Not in the cluster: a systemd unit on a host; the cluster cannot show it, so the declared machine stands. */
53 | { readonly kind: "host"; readonly unit: string }
54 /** A pod whose namespace is not recorded: not looked for, since that would list every pod in the cluster. */
55 | { readonly kind: "unlocated" };
56
57 export interface Piece {
58 readonly id: string;
59 readonly need: Need;
60 readonly what: string;
61 readonly find: Find;
62 /** Where it ran when the list was written. */
63 readonly machine: Machine;
64 /** What happens to the forge if that machine dies. */
65 readonly ifDies: string;
66 /** The issues that remove the dependency; none for a piece on the forge's own machine. */
67 readonly issues: readonly number[];
68 /** With pg-grand switched off, the step at which this piece fails the forge (1 first). */
69 readonly breaks?: number;
70 }
71
72 export const NEEDS: readonly Need[] = ["answer", "sign-in", "build", "release", "backup"];
73
74 /** The steps in which the forge breaks if pg-grand is switched off now, in order. */
75 export const BREAK_STEPS: readonly string[] = [
76 "Traefik: nothing public reaches the forge",
77 "plat-auth: no one signs in",
78 "the signup gate: no one signs up",
79 "image pulls and releases",
80 "the control plane: nothing reschedules, Services stop updating",
81 "local backups",
82 "the host timers",
83 ];
84
85 const SAME_DEATH = "It goes with the forge, which runs beside it.";
86
87 export const FOOTING: readonly Piece[] = [
88 {
89 id: "forge",
90 need: "answer",
91 what: "The forge's pod (git.open-platform.sh/trevato-apps/forge:<version>) and its data, PVC forge-data-vxrail (60 Gi)",
92 find: { kind: "self" },
93 machine: "vxrail",
94 ifDies: "The forge is down, and its data is on that disk.",
95 issues: [],
96 },
97 {
98 id: "tunnel",
99 need: "answer",
100 what: "Cloudflare tunnel carrying forge.open-platform.sh (standby/cloudflared), moved to the vxrail by hand on 2026-11-12; its last-applied manifest still says 0 replicas, so a re-render moves it back",
101 find: { kind: "pods", namespace: "standby", workload: "cloudflared" },
102 machine: "vxrail",
103 ifDies: SAME_DEATH,
104 issues: [8948, 11686],
105 },
106 {
107 id: "tunnel-home",
108 need: "answer",
109 what: "The vxrail's own tunnel (standby/cloudflared-forge-home, 2 replicas): it too sends *.open-platform.sh to Traefik; only forge-home-test.open-platform.sh and arm--forge-trevato-apps.open-platform.sh go straight to the forge",
110 find: { kind: "pods", namespace: "standby", workload: "cloudflared-forge-home" },
111 machine: "vxrail",
112 ifDies: SAME_DEATH,
113 issues: [],
114 },
115 {
116 id: "traefik",
117 need: "answer",
118 what: "Traefik (kube-system/traefik): every public request goes tunnel → Traefik → the forge's Service",
119 find: { kind: "pods", namespace: "kube-system", workload: "traefik" },
120 machine: "pg-grand",
121 ifDies: "The forge is off the internet; it keeps running inside.",
122 issues: [11686, 8946],
123 breaks: 1,
124 },
125 {
126 id: "hosts",
127 need: "answer",
128 what: "Child and app hosts, Traefik IngressRoutes forge-children (<name>--forge.open-platform.sh), forge-apps (*--forge-trevato-apps.open-platform.sh) and forge-old-host, and game.cheffryjames.com and www.cheffryjames.com in the forge's namespace",
129 find: { kind: "pods", namespace: "kube-system", workload: "traefik" },
130 machine: "pg-grand",
131 ifDies: "No child forge, app or custom domain answers.",
132 issues: [11686, 8946],
133 breaks: 1,
134 },
135 {
136 id: "forge-ssh",
137 need: "answer",
138 what: "forge-ssh, a host socket proxy (:2222 → the forge's ssh service)",
139 find: { kind: "host", unit: "forge-ssh" },
140 machine: "pg-grand",
141 ifDies: "git over ssh and the terminal stop answering.",
142 issues: [11686],
143 breaks: 7,
144 },
145 {
146 id: "control-plane",
147 need: "answer",
148 what: "The k3s API server (the vxrail is an agent joined to it)",
149 find: { kind: "control-plane" },
150 machine: "pg-grand",
151 ifDies: "Running pods keep running, but nothing reschedules, Services stop updating, and no release rolls or Job runs.",
152 issues: [11686],
153 breaks: 5,
154 },
155 {
156 id: "plat-auth",
157 need: "sign-in",
158 what: "Sign-in edge: a forwardAuth middleware on the forge's Ingress, answered by mcp/plat-auth",
159 find: { kind: "pods", namespace: "mcp", workload: "plat-auth" },
160 machine: "pg-grand",
161 ifDies: "Every request through the edge answers 500, /healthz too.",
162 issues: [11686],
163 breaks: 2,
164 },
165 {
166 id: "signup-gate",
167 need: "sign-in",
168 what: "forge-signup-gate, the pod /signup is routed to",
169 // An Ingress routes only to a Service in its own namespace: the forge's.
170 find: { kind: "pods", namespace: "", workload: "forge-signup-gate" },
171 machine: "pg-grand",
172 ifDies: "No one can sign up.",
173 issues: [11686],
174 breaks: 3,
175 },
176 {
177 id: "forgejo",
178 need: "build",
179 what: "Forgejo (git.open-platform.sh) and its Actions, which build the release image",
180 find: { kind: "pods", namespace: "forgejo", workload: "*" },
181 machine: "pg-grand",
182 ifDies: "No release image is built.",
183 issues: [11686, 8941],
184 breaks: 4,
185 },
186 {
187 id: "cnpg",
188 need: "build",
189 what: "Forgejo's database, a single instance under CloudNativePG (cnpg-system)",
190 find: { kind: "pods", namespace: "cnpg-system", workload: "*" },
191 machine: "pg-grand",
192 ifDies: "Forgejo stops, and with it builds.",
193 issues: [11686, 8941],
194 breaks: 4,
195 },
196 {
197 id: "runner",
198 need: "build",
199 what: "forgejo-runner-vxrail, a Forgejo Actions runner",
200 find: { kind: "unlocated" },
201 machine: "vxrail",
202 ifDies: SAME_DEATH,
203 issues: [],
204 },
205 {
206 id: "registry",
207 need: "release",
208 what: "Forgejo's registry (git.open-platform.sh), which the forge's image is pulled from",
209 find: { kind: "pods", namespace: "forgejo", workload: "*" },
210 machine: "pg-grand",
211 ifDies: "A pod that restarts on a node without the image cached cannot start; nothing new is released.",
212 issues: [11686, 8941, 8942],
213 breaks: 4,
214 },
215 {
216 id: "registry-node",
217 need: "release",
218 what: "registry-node, a registry on the vxrail",
219 find: { kind: "unlocated" },
220 machine: "vxrail",
221 ifDies: SAME_DEATH,
222 issues: [],
223 },
224 {
225 id: "release-tick",
226 need: "release",
227 what: "forge-release-tick, a host timer every 5 min",
228 find: { kind: "host", unit: "forge-release-tick" },
229 machine: "pg-grand",
230 ifDies: "Nothing is released; production keeps the release it has.",
231 issues: [11686, 8942],
232 breaks: 7,
233 },
234 {
235 id: "standby-reconcile",
236 need: "release",
237 what: "standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended",
238 find: { kind: "host", unit: "standby-reconcile" },
239 machine: "pg-grand",
240 ifDies: "A changed manifest is not applied; the running pod is untouched.",
241 issues: [8948, 11686],
242 breaks: 7,
243 },
244 {
245 id: "host-timers",
246 need: "release",
247 what: "Host timers forge-watchdog, forge-github-mirror and forge-prune-images",
248 find: { kind: "host", unit: "forge-watchdog" },
249 machine: "pg-grand",
250 ifDies: "The watchdog, the GitHub mirror and image pruning stop.",
251 issues: [11686],
252 breaks: 7,
253 },
254 {
255 id: "backup-lanes",
256 need: "backup",
257 what: "The backup namespace's CronJobs (FORGE_BACKUP_LANES)",
258 find: { kind: "pods", namespace: "backup", workload: "*" },
259 machine: "pg-grand",
260 ifDies: "No new backup is taken.",
261 issues: [11686],
262 breaks: 6,
263 },
264 {
265 id: "minio",
266 need: "backup",
267 what: "MinIO, where the backup lanes store the forge's backups",
268 find: { kind: "pods", namespace: "minio", workload: "*" },
269 machine: "pg-grand",
270 ifDies: "No new backup is stored, and those it holds go with the machine.",
271 issues: [11686],
272 breaks: 6,
273 },
274 ];
275
276 /** On pg-grand, not in the forge's path: what dropping the machine takes besides. */
277 export const ALSO_ON_PG_GRAND =
278 "Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO.";
279
280 /** How each piece was placed: seen on nodes, or the declared machine kept and why. */
281 export type Seen =
282 | { readonly by: "cluster"; readonly nodes: readonly string[] }
283 | { readonly by: "declared"; readonly why: string };
284
285 export interface FootingRow extends Piece {
286 readonly seen: Seen;
287 /** True when the piece is not on the forge's own machine (or cannot be shown to be). */
288 readonly risk: boolean;
289 }
290
291 export interface Footing {
292 /** The forge's own node; null outside a cluster or when its pod cannot be read. */
293 readonly forgeNode: string | null;
294 /** Why the cluster was not read, when it was not. */
295 readonly unread: string | null;
296 readonly rows: readonly FootingRow[];
297 readonly at: string;
298 }
299
300 /** A pod belongs to a workload: `<w>-<rs>-<id>` (Deployment), `<w>-<n>` (StatefulSet), `<w>-<id>` (DaemonSet). */
301 export function ownedBy(pod: string, workload: string): boolean {
302 if (workload === "*") return true;
303 if (!pod.startsWith(`${workload}-`)) return false;
304 const rest = pod.slice(workload.length + 1);
305 return /^[a-z0-9]{5,10}-[a-z0-9]{5}$/.test(rest) || /^\d+$/.test(rest) || /^[a-z0-9]{5}$/.test(rest);
306 }
307
308 /** Rows for the declared list alone: every piece placed on its declared machine. */
309 export function declaredFooting(why: string, at = new Date()): Footing {
310 return {
311 forgeNode: null,
312 unread: why,
313 at: at.toISOString(),
314 rows: FOOTING.map((p) => ({ ...p, seen: { by: "declared", why }, risk: p.machine !== FORGE_MACHINE })),
315 };
316 }
317
318 type KubeFetch = (url: string, init: RequestInit & { tls?: { ca: string } }) => Promise<Response>;
319
320 export interface ProbeOptions {
321 fetch?: KubeFetch;
322 /** The forge's service account, or how to read it (it throws outside a cluster). */
323 credentials?: Credentials | (() => Credentials);
324 /** The forge's own pod name; the kubelet sets HOSTNAME to it. */
325 podName?: string;
326 now?: () => Date;
327 }
328
329 type PodList = { items?: { metadata?: { name?: string }; spec?: { nodeName?: string }; status?: { phase?: string } }[] };
330 type NodeList = { items?: { metadata?: { name?: string; labels?: Record<string, string> } }[] };
331
332 const CONTROL_LABELS = ["node-role.kubernetes.io/control-plane", "node-role.kubernetes.io/master"];
333
334 /** Read the live cluster; never throws. Outside a cluster, the declared list with why. */
335 export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> {
336 const now = opts.now ?? (() => new Date());
337 let cred: Credentials;
338 try {
339 const c = opts.credentials ?? serviceAccount;
340 cred = typeof c === "function" ? c() : c;
341 } catch (e) {
342 return declaredFooting(`not read: ${e instanceof Error ? e.message : String(e)}`, now());
343 }
344 const doFetch: KubeFetch = opts.fetch ?? ((u, i) => fetch(u, i as RequestInit));
345 const get = async <T>(path: string): Promise<{ ok: true; value: T } | { ok: false; status: number | string }> => {
346 try {
347 const res = await doFetch(`${cred.server}${path}`, {
348 method: "GET",
349 headers: { authorization: `Bearer ${cred.token}`, accept: "application/json" },
350 signal: AbortSignal.timeout(3000),
351 ...(cred.ca ? { tls: { ca: cred.ca } } : {}),
352 });
353 if (!res.ok) return { ok: false, status: res.status };
354 return { ok: true, value: (await res.json()) as T };
355 } catch (e) {
356 return { ok: false, status: e instanceof Error ? e.message : "unreachable" };
357 }
358 };
359 const refused = (s: number | string, what: string): string =>
360 s === 403 ? `the forge's service account may not read ${what}` : `${what} not read (${s})`;
361
362 const podName = opts.podName ?? process.env["HOSTNAME"] ?? "";
363 const self = podName
364 ? await get<{ spec?: { nodeName?: string } }>(`/api/v1/namespaces/${encodeURIComponent(cred.namespace)}/pods/${encodeURIComponent(podName)}`)
365 : null;
366 const forgeNode = self?.ok ? (self.value.spec?.nodeName ?? null) : null;
367
368 const nodes = await get<NodeList>("/api/v1/nodes");
369 const nsOf = (ns: string): string => ns || cred.namespace;
370 const namespaces = [...new Set(FOOTING.flatMap((p) => (p.find.kind === "pods" ? [nsOf(p.find.namespace)] : [])))];
371 const pods = new Map(
372 await Promise.all(namespaces.map(async (ns) => [ns, await get<PodList>(`/api/v1/namespaces/${encodeURIComponent(ns)}/pods`)] as const)),
373 );
374 const controlNodes = nodes.ok
375 ? (nodes.value.items ?? []).filter((n) => CONTROL_LABELS.some((l) => n.metadata?.labels?.[l] !== undefined)).map((n) => n.metadata?.name ?? "").filter(Boolean)
376 : null;
377
378 const place = (p: Piece): Seen => {
379 const f = p.find;
380 if (f.kind === "self") {
381 if (forgeNode !== null) return { by: "cluster", nodes: [forgeNode] };
382 return { by: "declared", why: self === null ? "the forge's pod name is unknown" : self.ok ? "its pod has no node yet" : refused(self.status, "its own pod") };
383 }
384 if (f.kind === "control-plane") {
385 if (!nodes.ok) return { by: "declared", why: refused(nodes.status, "nodes") };
386 return controlNodes!.length ? { by: "cluster", nodes: controlNodes! } : { by: "declared", why: "no node is labelled control-plane" };
387 }
388 // Nothing in the cluster sees a systemd unit: the declared machine stands, said as such.
389 if (f.kind === "host") return { by: "declared", why: `${f.unit} is a systemd unit on a host; the cluster cannot show it` };
390 if (f.kind === "unlocated") return { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" };
391 const where = nsOf(f.namespace);
392 const list = pods.get(where)!;
393 if (!list.ok) return { by: "declared", why: refused(list.status, `pods in ${where}`) };
394 const on = (list.value.items ?? [])
395 .filter((i) => ownedBy(i.metadata?.name ?? "", f.workload) && i.status?.phase !== "Succeeded" && i.status?.phase !== "Failed")
396 .map((i) => i.spec?.nodeName ?? "")
397 .filter(Boolean);
398 return on.length ? { by: "cluster", nodes: [...new Set(on)].sort() } : { by: "declared", why: `no pod running in ${where} now` };
399 };
400
401 const rows = FOOTING.map((p): FootingRow => {
402 const seen = place(p);
403 const risk =
404 seen.by === "cluster" && forgeNode !== null
405 ? seen.nodes.some((n) => n !== forgeNode)
406 : p.machine !== FORGE_MACHINE;
407 return { ...p, seen, risk };
408 });
409 const unread =
410 forgeNode !== null ? null
411 : self === null ? "the forge's pod name is unknown (HOSTNAME unset)"
412 : self.ok ? "the forge's own pod has no node yet"
413 : refused(self.status, "its own pod");
414 return { forgeNode, unread, rows, at: now().toISOString() };
415 }
416
417 /** Where a row was placed, in words. */
418 export function whereWords(r: FootingRow): string {
419 return r.seen.by === "cluster" ? r.seen.nodes.join(", ") : `${r.machine} (declared: ${r.seen.why})`;
420 }
421
422 /** The rows off the forge's machine, in the order pg-grand switched off would break the forge; ties keep the list's order. */
423 export function offMachine(f: Footing): FootingRow[] {
424 return f.rows
425 .filter((r) => r.risk)
426 .map((r, i) => ({ r, i }))
427 .sort((a, b) => (a.r.breaks ?? 99) - (b.r.breaks ?? 99) || a.i - b.i)
428 .map(({ r }) => r);
429 }
430
431 /** The risk lines: one per required piece off the forge's machine, in the order they would break it. */
432 export function riskLines(f: Footing): string[] {
433 return offMachine(f).map((r) => `${r.need}: ${r.what} on ${whereWords(r)}. ${r.ifDies}`);
434 }
435
436 const FRESH_MS = 60_000;
437 /** After a probe that throws, the next is not started for this long, so a broken cluster costs one try a minute. */
438 const BACKOFF_MS = 60_000;
439 const WAIT_MS = 800;
440
441 /** The last reading shared by /system, its terminal screen and the MCP answer. */
442 export function footingCache(probe: () => Promise<Footing> = () => probeFooting(), clock: () => number = Date.now) {
443 let last: { at: number; value: Footing } | null = null;
444 let pending: Promise<Footing> | null = null;
445 let failedAt: number | null = null;
446 const stale = (): boolean => !last || clock() - last.at > FRESH_MS;
447 const backingOff = (): boolean => failedAt !== null && clock() - failedAt < BACKOFF_MS;
448 const refresh = (): Promise<Footing> => {
449 pending ??= probe()
450 .then((value) => {
451 last = { at: clock(), value };
452 failedAt = null;
453 return value;
454 })
455 .catch((e: unknown) => {
456 failedAt = clock();
457 throw e;
458 })
459 .finally(() => {
460 pending = null;
461 });
462 return pending;
463 };
464 return {
465 /** The last reading, refreshed in the background when older than 60 s; null before the first. */
466 peek(): Footing | null {
467 if (stale() && !backingOff()) void refresh().catch(() => {});
468 return last?.value ?? null;
469 },
470 /** A fresh-enough reading, waiting at most `waitMs` for the cluster; the declared list when it is slower. */
471 async read(waitMs = WAIT_MS): Promise<Footing> {
472 if (!stale()) return last!.value;
473 if (backingOff()) return last?.value ?? declaredFooting("the cluster did not answer; it is asked again within a minute");
474 let deadline: ReturnType<typeof setTimeout> | undefined;
475 const timer = new Promise<null>((r) => {
476 deadline = setTimeout(() => r(null), waitMs);
477 });
478 try {
479 const got = await Promise.race([refresh().catch(() => null), timer]);
480 return got ?? last?.value ?? declaredFooting("the cluster did not answer in time; reload in a moment");
481 } finally {
482 clearTimeout(deadline);
483 }
484 },
485 };
486 }
487
488 export const footing = footingCache();
489
490 const NEED_WORDS: Record<Need, string> = {
491 answer: "(a) answer a request",
492 "sign-in": "(b) sign a person in",
493 build: "(c) build",
494 release: "(d) release",
495 backup: "(e) back up",
496 };
497
498 /** Markdown text: a pipe would end a cell, and `<name>` would read as a tag. */
499 const cell = (s: string): string => s.replaceAll("|", "\\|").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
500
501 /** What `docs/OPERATING.md` carries between its markers, written from `MACHINES`, `FOOTING` and `BREAK_STEPS`. */
502 export function footingTable(): string {
503 const machines = (Object.keys(MACHINES) as Machine[]).map((m) => `- **${m}**: ${cell(MACHINES[m])}`);
504 const head = "| Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by |\n|---|---|---|---|---|---|";
505 const lines = NEEDS.flatMap((need) =>
506 FOOTING.filter((p) => p.need === need).map(
507 (p) =>
508 `| ${NEED_WORDS[need]} | ${cell(p.what)} | ${p.machine} | ${cell(p.ifDies)} | ${p.breaks ?? "—"} | ${p.issues.map((n) => `#${n}`).join(", ") || "—"} |`,
509 ),
510 );
511 const steps = BREAK_STEPS.map((s, i) => `${i + 1}. ${cell(s)}`);
512 return [
513 ...machines,
514 "",
515 head,
516 ...lines,
517 "",
518 "If pg-grand were switched off now, the forge breaks in this order (the Breaks column):",
519 "",
520 ...steps,
521 "",
522 `Also on pg-grand, not in the forge's path: ${cell(ALSO_ON_PG_GRAND)}`,
523 ].join("\n");
524 }
src/tui/screens/entries.ts +2
@@ -57,6 +57,7 @@ import { entry as plans } from "./plans.ts";
5757 import { entry as economics } from "./economics.ts";
5858 import { entry as money } from "./money.ts";
5959 import { entry as loopStalls } from "./loopstalls.ts";
60 import { entry as footing } from "./footing.ts";
6061 import { entry as waiting } from "./waiting.ts";
6162 import { entry as slowPagesEntry } from "./slowpages.ts";
6263 import { entry as maintenance } from "./maintenance.ts";
@@ -122,6 +123,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
122123 homePins,
123124 system,
124125 loopStalls,
126 footing,
125127 waiting,
126128 slowPagesEntry,
127129 telemetry,
src/tui/screens/footing.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * What the forge stands on, in the terminal (#11685): the rows /system shows
3 * under the same heading. First where the forge runs (or why that was not
4 * read), then each piece on another machine, in the order switching pg-grand
5 * off would break the forge: its step, what it is needed for, the piece, the
6 * machine it was found on, and what happens if that machine dies. Last, the
7 * pieces on the forge's own machine, by name. The last reading
8 * (`footing.peek`), so the screen never waits on the cluster; the declared
9 * list while the first read runs. The gate is /system's: a forge admin who is
10 * a person. Read-only.
11 */
12
13 import { BREAK_STEPS, declaredFooting, footing, offMachine, whereWords, type Footing } from "../../system/footing.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Session } from "../session.ts";
17 import { style } from "../term/style.ts";
18 import { pad } from "../term/width.ts";
19 import { Scroll, drawLines, hitScroll } from "./common.ts";
20 import type { PaletteEntry } from "./entries.ts";
21
22 const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
23
24 const current = (): Footing => footing.peek() ?? declaredFooting("the cluster is being read; reopen in a moment");
25
26 /** The screen's lines; empty for anyone the gate refuses. */
27 export function footingLines(session: Session, f: Footing = current()): string[] {
28 if (!mayRead(session)) return [];
29 const off = offMachine(f);
30 const home = f.rows.filter((r) => !r.risk);
31 const where =
32 f.forgeNode === null ? `The forge's node was not read (${f.unread ?? "unknown"}); machines are as declared.` : `The forge runs on ${f.forgeNode}.`;
33 const lines = [where, ""];
34 if (off.length === 0) lines.push("Every piece runs on the forge's own machine.");
35 else {
36 lines.push(`${String(off.length)} of ${String(f.rows.length)} pieces run on another machine. With pg-grand off, they fail in this order:`);
37 for (const r of off) {
38 const step = r.breaks === undefined ? "-" : String(r.breaks);
39 lines.push("", `${pad(step, 3, "right")} ${pad(r.need, 8)} ${r.what}`);
40 lines.push(`${pad("", 15)}on ${whereWords(r)}`, `${pad("", 15)}if it dies: ${r.ifDies}`);
41 }
42 lines.push("", "The steps:", ...BREAK_STEPS.map((s, i) => `${pad(String(i + 1), 3, "right")} ${s}`));
43 }
44 if (home.length > 0) lines.push("", `On the forge's own machine: ${home.map((r) => r.id).join(", ")}`);
45 return lines;
46 }
47
48 export function footingScreen(session: Session, ctx: Ctx): Screen {
49 const scroll = new Scroll();
50 return {
51 title: "What the forge stands on",
52 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
53 render(area, s) {
54 const c = ctx.theme.c;
55 const lines = footingLines(session);
56 drawLines(s, area, c, scroll, lines.map((text) => [{ text, style: style(/^\s*\d+ {2}/.test(text) || text === "" ? c.fg : c.mutedFg, c.bg) }]));
57 hitScroll(ctx, area, scroll, lines.length);
58 },
59 key(e) {
60 if (scroll.key(e, footingLines(session).length)) return true;
61 if (e.key === "esc" || e.key === "q") {
62 ctx.pop();
63 return true;
64 }
65 return false;
66 },
67 };
68 }
69
70 export const entry: PaletteEntry = {
71 id: "footing",
72 title: "What the forge stands on",
73 hint: "each piece on another machine, in the order it would break the forge",
74 after: ["stalls", "system"],
75 offered: mayRead,
76 run: (session, ctx) => ctx.push(footingScreen(session, ctx)),
77 };
src/web/pages/system.ts +36
@@ -47,6 +47,7 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts";
4747 import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts";
4848 import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts";
4949 import { egressReportedLine } from "../../system/egress_line.ts";
50 import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords, type Footing, type Machine } from "../../system/footing.ts";
5051 import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts";
5152 import { childDirOf, listChildren } from "../../actions/spawn.ts";
5253 import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts";
@@ -204,6 +205,38 @@ export function featuresSection(rows: readonly FeatureHealth[]): Html {
204205 <p class="small"><a href="/settings/features">Settings › Features</a></p></section>`;
205206 }
206207
208 /**
209 * What the forge stands on (#11685): each piece it needs to answer, sign in,
210 * build, release and back up, and where it was found. The summary gives the
211 * one count; inside, each piece on another machine, in the order switching
212 * pg-grand off would break the forge, one fact a line; then the pieces on the
213 * forge's own machine, by name. `system/footing.ts`'s rows.
214 */
215 export function footingSection(f: Footing): Html {
216 const off = offMachine(f);
217 const home = f.rows.filter((r) => !r.risk);
218 // First sight (#11111): the heading and one count stay; the rows open behind one disclosure.
219 const count = off.length === 0 ? "Every piece runs on the forge's own machine" : `${off.length} of ${f.rows.length} pieces run on another machine`;
220 return html`<section class="settings-block" id="sys-footing" data-off="${String(off.length)}" data-forge-node="${f.forgeNode ?? ""}"><h2>What the forge stands on</h2>
221 ${disclosure(count, html`<p class="small">${f.forgeNode === null ? html`The forge's node was not read (${f.unread ?? "unknown"}), so each piece shows the machine written in <code>docs/OPERATING.md</code>.` : html`The forge runs on <span class="mono">${f.forgeNode}</span>.`}
222 ${off.length === 0 ? null : "If that machine stops, each piece below stops with it; they are listed in the order the forge would lose them."}</p>
223 ${off.length === 0 ? null : html`<ul class="list" aria-label="Pieces on another machine">${off.map((r) =>
224 row({
225 title: r.what,
226 facts: [
227 { label: "Needed to", value: r.need },
228 { label: "Runs on", value: html`<span class="mono">${whereWords(r)}</span>` },
229 { label: "If it stops", value: r.ifDies },
230 ...(r.breaks ? [{ label: "Order", value: `${r.breaks} of ${BREAK_STEPS.length}: ${BREAK_STEPS[r.breaks - 1]}` }] : []),
231 ...(r.issues.length ? [{ label: "Fixed by", value: r.issues.map((n) => `#${n}`).join(", ") }] : []),
232 ],
233 data: { piece: r.id, off: "true", seen: r.seen.by, ...(r.breaks ? { breaks: String(r.breaks) } : {}) },
234 }),
235 )}</ul>`}
236 ${home.length === 0 ? null : html`<p class="small muted">On the forge's own machine: ${home.map((r, i) => html`${i ? ", " : ""}<span data-piece="${r.id}" data-off="false">${r.what}</span>`)}.</p>`}
237 ${(Object.keys(MACHINES) as Machine[]).map((m) => html`<p class="small muted" data-machine="${m}"><span class="mono">${m}</span>: ${MACHINES[m]}</p>`)}`, { id: "sys-footing-more" })}</section>`;
238 }
239
207240 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
208241 export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html {
209242 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>${
@@ -370,6 +403,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
370403 const stalls = timePhase(ctx.request, "stalls", () => stallHour(loopWatch.tally(performance.now())));
371404 const nowMono = performance.now();
372405 const worst = stallBlame.worst(nowMono, HOUR_MS, WORST_STALLS);
406 // Never waits on the cluster: the last reading, or the declared rows while the first read runs behind.
407 const standsOn = footing.peek() ?? declaredFooting("the cluster is being read; reload in a moment");
373408 const rendering = performance.now();
374409 const r = regions(v);
375410 const at = (id: string): Html => raw(r[id]!);
@@ -403,6 +438,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
403438 <div class="js-only small muted">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button>
404439 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
405440 <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section>
441 ${footingSection(standsOn)}
406442 ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))}
407443 ${lagSection(lagMinutes.readings())}
408444 <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div>
test/footing.test.ts added +404
@@ -0,0 +1,404 @@
1 /**
2 * What the forge stands on (#11685): the table in docs/OPERATING.md is the
3 * declared list exactly; the probe places each piece on the node the kube API
4 * shows it on and marks every piece off the forge's node a risk; what the
5 * cluster cannot show keeps its declared machine and says why; /system shows
6 * it under one heading, and the MCP `system` tool answers it to an admin only.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, readFile, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createToken } from "../src/core/tokens.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import {
17 BREAK_STEPS,
18 FOOTING,
19 declaredFooting,
20 footingCache,
21 footingTable,
22 ownedBy,
23 probeFooting,
24 riskLines,
25 type Footing,
26 } from "../src/system/footing.ts";
27 import { footingSection } from "../src/web/pages/system.ts";
28 import { getUserByName, type User } from "../src/core/users.ts";
29 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
30 import { footingLines, footingScreen } from "../src/tui/screens/footing.ts";
31 import { headlessTerminal } from "../src/tui/term/terminal.ts";
32 import { settleApp } from "./helpers/tui.ts";
33 import { quietLogs } from "./helpers/globals.ts";
34
35 quietLogs();
36
37 const cred = { server: "https://kube.test", token: "t", namespace: "forge" };
38 const pod = (name: string, node: string, phase = "Running") => ({ metadata: { name }, spec: { nodeName: node }, status: { phase } });
39
40 /**
41 * A fake kube API as the steward found it on 2026-11-12: the forge and both tunnels on vxrail-1,
42 * the rest on pg-grand, minio forbidden; Traefik moved to vxrail-1.
43 */
44 function fakeKube(over: Record<string, unknown> = {}) {
45 const answers: Record<string, unknown> = {
46 "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": { spec: { nodeName: "vxrail-1" } },
47 "/api/v1/nodes": {
48 items: [
49 { metadata: { name: "pg-grand", labels: { "node-role.kubernetes.io/control-plane": "true" } } },
50 { metadata: { name: "vxrail-1", labels: { "plat.sh/site": "vxrail" } } },
51 ],
52 },
53 "/api/v1/namespaces/standby/pods": {
54 items: [pod("cloudflared-6c7b9d8f4-x2x9z", "vxrail-1"), pod("cloudflared-forge-home-5f6d7c8b9-q1w2e", "vxrail-1"), pod("cloudflared-forge-home-5f6d7c8b9-r3t4y", "vxrail-1")],
55 },
56 "/api/v1/namespaces/forge/pods": { items: [pod("forge-7d9f8c6b5-abcde", "vxrail-1"), pod("forge-signup-gate-6d5c4b3a2-sgate", "pg-grand")] },
57 "/api/v1/namespaces/kube-system/pods": { items: [pod("traefik-5b8d9c7f6-zzzzz", "vxrail-1"), pod("coredns-1234567890-aaaaa", "pg-grand")] },
58 "/api/v1/namespaces/mcp/pods": { items: [pod("plat-auth-7f8e9d6c5-abcde", "pg-grand")] },
59 "/api/v1/namespaces/forgejo/pods": { items: [pod("forgejo-0", "pg-grand"), pod("forgejo-db-1", "pg-grand")] },
60 "/api/v1/namespaces/flux-system/pods": { items: [pod("source-controller-6b5d4c3f2-qwert", "pg-grand")] },
61 "/api/v1/namespaces/backup/pods": { items: [pod("lane-forge-29000000-abcde", "pg-grand", "Succeeded")] },
62 "/api/v1/namespaces/minio/pods": 403,
63 "/api/v1/namespaces/cnpg-system/pods": { items: [pod("cnpg-controller-manager-7d8f9b6c5-mnbvc", "pg-grand")] },
64 ...over,
65 };
66 const seen: string[] = [];
67 const fetch = async (url: string, init: RequestInit) => {
68 const path = url.slice(cred.server.length);
69 seen.push(path);
70 expect((init.headers as Record<string, string>)["authorization"]).toBe("Bearer t");
71 expect(init.method).toBe("GET");
72 const a = answers[path];
73 if (a === undefined) return new Response("{}", { status: 404 });
74 if (typeof a === "number") return new Response("{}", { status: a });
75 return Response.json(a);
76 };
77 return { fetch, seen };
78 }
79
80 const byId = (f: Footing, id: string) => f.rows.find((r) => r.id === id)!;
81
82 describe("the table in docs/OPERATING.md", () => {
83 test("is footingTable() exactly, between the markers", async () => {
84 const doc = await readFile(join(import.meta.dir, "..", "docs", "OPERATING.md"), "utf8");
85 const m = doc.match(/<!-- footing:start[^>]*-->\n([\s\S]*?)\n<!-- footing:end -->/);
86 expect(m).not.toBeNull();
87 expect(m![1]).toBe(footingTable());
88 expect(doc).toContain("## What the forge stands on");
89 });
90 test("every need (a) to (e) has a piece; every pg-grand piece names an issue that removes it and the step it breaks at", () => {
91 for (const need of ["answer", "sign-in", "build", "release", "backup"]) expect(FOOTING.some((p) => p.need === need)).toBe(true);
92 for (const p of FOOTING.filter((p) => p.machine === "pg-grand")) {
93 expect(p.issues.length).toBeGreaterThan(0);
94 expect(p.breaks! >= 1 && p.breaks! <= BREAK_STEPS.length).toBe(true);
95 }
96 for (const p of FOOTING.filter((p) => p.machine === "vxrail")) expect(p.breaks).toBeUndefined();
97 // Every step of the break order has a piece behind it.
98 for (let i = 1; i <= BREAK_STEPS.length; i++) expect(FOOTING.some((p) => p.breaks === i)).toBe(true);
99 expect(new Set(FOOTING.map((p) => p.id)).size).toBe(FOOTING.length);
100 });
101 test("the doc names both machines and escapes what markdown would read as a tag", async () => {
102 const t = footingTable();
103 expect(t).toContain("- **pg-grand**: DigitalOcean.");
104 expect(t).toContain("- **vxrail**: ");
105 expect(t).toContain("forge:&lt;version&gt;");
106 expect(t).not.toContain("<version>");
107 expect(t).toContain("1. Traefik: nothing public reaches the forge");
108 // No figure that goes stale is written as if current: no disk fill, sizes or counts of the day.
109 expect(t).not.toMatch(/% used|\b\d+(\.\d+)? (GB|TB)\b|\b\d+ (single-instance )?databases\b|\b\d+ (local-path )?volumes\b/);
110 });
111 });
112
113 describe("ownedBy", () => {
114 test("a Deployment's, StatefulSet's and DaemonSet's pods; not a longer workload's", () => {
115 expect(ownedBy("cloudflared-6c7b9d8f4-x2x9z", "cloudflared")).toBe(true);
116 expect(ownedBy("forgejo-0", "forgejo")).toBe(true);
117 expect(ownedBy("traefik-abcde", "traefik")).toBe(true);
118 expect(ownedBy("cloudflared-forge-home-5f6d7c8b9-q1w2e", "cloudflared")).toBe(false);
119 expect(ownedBy("plat-auth-x", "plat-auth")).toBe(false);
120 expect(ownedBy("anything", "*")).toBe(true);
121 });
122 });
123
124 describe("probeFooting", () => {
125 test("places each piece on the node it runs on; off the forge's node is a risk", async () => {
126 const kube = fakeKube();
127 const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde", now: () => new Date("2026-11-12T12:00:00Z") });
128 expect(f.forgeNode).toBe("vxrail-1");
129 expect(f.unread).toBeNull();
130 expect(f.at).toBe("2026-11-12T12:00:00.000Z");
131 // The forge's own pod, and the tunnel moved to the vxrail by hand: on the forge's node.
132 expect(byId(f, "forge")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false });
133 expect(byId(f, "tunnel")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false });
134 expect(byId(f, "tunnel-home")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false });
135 // The signup gate is looked for in the forge's own namespace (an Ingress routes only within its own).
136 expect(byId(f, "signup-gate")).toMatchObject({ seen: { by: "cluster", nodes: ["pg-grand"] }, risk: true });
137 // A pod whose namespace is not recorded is not looked for: no list of every pod in the cluster.
138 expect(byId(f, "runner")).toMatchObject({ seen: { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" }, risk: false });
139 expect(byId(f, "registry-node")).toMatchObject({ seen: { by: "declared" }, risk: false });
140 expect(kube.seen).not.toContain("/api/v1/pods");
141 expect(kube.seen.every((p) => p === "/api/v1/nodes" || p.startsWith("/api/v1/namespaces/"))).toBe(true);
142 // Traefik moved to the forge's node: no longer a risk, whatever the declared list said.
143 expect(byId(f, "traefik")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false });
144 expect(byId(f, "hosts").risk).toBe(false);
145 expect(byId(f, "control-plane")).toMatchObject({ seen: { by: "cluster", nodes: ["pg-grand"] }, risk: true });
146 // Nothing in the cluster sees a systemd unit: declared, said why, judged by the declared machine.
147 expect(byId(f, "release-tick")).toMatchObject({ seen: { by: "declared", why: "forge-release-tick is a systemd unit on a host; the cluster cannot show it" }, risk: true });
148 expect(byId(f, "standby-reconcile")).toMatchObject({ seen: { by: "declared" }, risk: true });
149 for (const id of ["forge-ssh", "release-tick", "standby-reconcile", "host-timers"]) expect(byId(f, id).seen.by).toBe("declared");
150 expect(byId(f, "plat-auth").risk).toBe(true);
151 // Forbidden: the declared machine is kept and the reason named.
152 expect(byId(f, "minio").seen).toEqual({ by: "declared", why: "the forge's service account may not read pods in minio" });
153 expect(byId(f, "minio").risk).toBe(true);
154 // A CronJob between runs: only a finished pod, so declared.
155 expect(byId(f, "backup-lanes").seen).toEqual({ by: "declared", why: "no pod running in backup now" });
156 // Each namespace is listed once although two pieces live in forgejo.
157 expect(kube.seen.filter((p) => p === "/api/v1/namespaces/forgejo/pods").length).toBe(1);
158 const off = ["forge-ssh", "control-plane", "plat-auth", "signup-gate", "forgejo", "cnpg", "registry", "release-tick", "standby-reconcile", "host-timers", "backup-lanes", "minio"];
159 expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual([...off].sort());
160 // In the order pg-grand switched off would break the forge: sign-in first, host timers last.
161 const lines = riskLines(f);
162 expect(lines.length).toBe(off.length);
163 expect(lines[0]).toStartWith("sign-in: Sign-in edge");
164 expect(lines[0]).toContain(" on pg-grand. Every request through the edge answers 500, /healthz too.");
165 expect(lines[1]).toStartWith("sign-in: forge-signup-gate");
166 expect(lines.at(-1)).toStartWith("release: Host timers forge-watchdog");
167 });
168
169 test("with every piece on the forge's node, there is no risk line", async () => {
170 const everyone = (names: string[]) => ({ items: names.map((n) => pod(n, "pg-grand")) });
171 const kube = fakeKube({
172 "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": { spec: { nodeName: "pg-grand" } },
173 "/api/v1/namespaces/standby/pods": everyone(["cloudflared-6c7b9d8f4-x2x9z", "cloudflared-forge-home-5f6d7c8b9-q1w2e"]),
174 "/api/v1/namespaces/forge/pods": everyone(["forge-signup-gate-6d5c4b3a2-sgate"]),
175 "/api/v1/namespaces/minio/pods": everyone(["minio-0"]),
176 "/api/v1/namespaces/backup/pods": everyone(["lane-forge-29000000-abcde"]),
177 "/api/v1/namespaces/kube-system/pods": everyone(["traefik-5b8d9c7f6-zzzzz"]),
178 });
179 // Every piece the cluster can show is on the forge's node; the host units are declared on pg-grand, so they remain.
180 const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" });
181 expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual(["forge-ssh", "host-timers", "release-tick", "standby-reconcile"]);
182 expect(f.rows.filter((r) => r.risk).every((r) => r.seen.by === "declared")).toBe(true);
183 });
184
185 test("the forge's own namespace refused: the signup gate keeps its declared machine and says why", async () => {
186 const kube = fakeKube({ "/api/v1/namespaces/forge/pods": 403 });
187 const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" });
188 expect(byId(f, "signup-gate")).toMatchObject({ seen: { by: "declared", why: "the forge's service account may not read pods in forge" }, risk: true });
189 });
190
191 test("outside a cluster: the declared list, said so, every pg-grand row a risk", async () => {
192 let fetched = 0;
193 const f = await probeFooting({
194 podName: "x",
195 credentials: () => {
196 throw new Error("not running in a cluster");
197 },
198 fetch: async () => {
199 fetched++;
200 return Response.json({});
201 },
202 });
203 expect(fetched).toBe(0);
204 expect(f.forgeNode).toBeNull();
205 expect(f.unread).toBe("not read: not running in a cluster");
206 expect(f.rows.every((r) => r.seen.by === "declared" && r.risk === (r.machine !== "vxrail"))).toBe(true);
207 });
208
209 test("the forge's own pod unreadable: its node is unknown and the declared machines decide risk", async () => {
210 const kube = fakeKube({ "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": 403 });
211 const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" });
212 expect(f.forgeNode).toBeNull();
213 expect(f.unread).toBe("the forge's service account may not read its own pod");
214 expect(byId(f, "traefik").risk).toBe(true);
215 expect(byId(f, "forge")).toMatchObject({ seen: { by: "declared", why: "the forge's service account may not read its own pod" }, risk: false });
216 });
217 });
218
219 describe("footingCache", () => {
220 test("a slow cluster gives the declared list at once, and the reading once it lands", async () => {
221 let release!: (f: Footing) => void;
222 const live = declaredFooting("live", new Date(0));
223 let calls = 0;
224 const cache = footingCache(() => {
225 calls++;
226 return new Promise<Footing>((r) => (release = r));
227 });
228 expect(cache.peek()).toBeNull();
229 const first = await cache.read(5);
230 expect(first.unread).toBe("the cluster did not answer in time; reload in a moment");
231 expect(calls).toBe(1); // peek's refresh is the one read waited on
232 release(live);
233 await Bun.sleep(0);
234 expect(cache.peek()).toBe(live);
235 expect(await cache.read(5)).toBe(live);
236 expect(calls).toBe(1);
237 });
238
239 test("a probe that throws is not retried for 60 s, by peek or by read", async () => {
240 let now = 1_000_000;
241 let calls = 0;
242 const cache = footingCache(async () => {
243 calls++;
244 throw new Error("kube down");
245 }, () => now);
246 expect(cache.peek()).toBeNull();
247 await Bun.sleep(0);
248 expect(calls).toBe(1);
249 now += 30_000;
250 expect(cache.peek()).toBeNull();
251 expect((await cache.read(5)).unread).toBe("the cluster did not answer; it is asked again within a minute");
252 await Bun.sleep(0);
253 expect(calls).toBe(1);
254 now += 30_001;
255 cache.peek();
256 await Bun.sleep(0);
257 expect(calls).toBe(2);
258 });
259
260 test("a fast read leaves no deadline timer behind", async () => {
261 const live = declaredFooting("live", new Date(0));
262 const cleared: unknown[] = [];
263 const real = globalThis.clearTimeout;
264 globalThis.clearTimeout = ((t: Parameters<typeof clearTimeout>[0]) => {
265 cleared.push(t);
266 real(t);
267 }) as typeof clearTimeout;
268 try {
269 expect(await footingCache(async () => live).read(60_000)).toBe(live);
270 } finally {
271 globalThis.clearTimeout = real;
272 }
273 expect(cleared.length).toBe(1);
274 expect(cleared[0]).toBeDefined();
275 });
276 });
277
278 describe("where it shows", () => {
279 let dir = "";
280 let app: App;
281 let servers: Servers;
282 const tok: Record<string, string> = {};
283 beforeAll(async () => {
284 dir = await mkdtemp(join(tmpdir(), "forge-footing-"));
285 app = createApp({ dataDir: dir, port: 0 });
286 servers = await serve(app);
287 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
288 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-good-passphrase", ...(first ? { adminIfFirst: true } : {}) });
289 if (!r.ok) throw new Error(r.error);
290 tok[username] = createToken(app.db, r.value.id, "t", "admin").token;
291 }
292 }, 30_000);
293 afterAll(async () => {
294 servers.stop();
295 app.close();
296 await rm(dir, { recursive: true, force: true });
297 });
298
299 test("the section has one heading, says the count once, and a row of facts per piece off the forge's machine, escaped", async () => {
300 const kube = fakeKube();
301 const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" });
302 const out = String(footingSection(f));
303 const off = f.rows.filter((r) => r.risk).length;
304 expect(out.match(/<h2>What the forge stands on<\/h2>/g)?.length).toBe(1);
305 expect(out).toContain(`data-off="${off}"`);
306 expect(out.match(new RegExp(`${off} of ${f.rows.length}`, "g"))?.length).toBe(1);
307 expect(out).toContain('data-piece="traefik" data-off="false"');
308 expect(out).toMatch(/data-piece="plat-auth" data-off="true"/);
309 expect(out).toContain('data-breaks="2"');
310 // Off-machine rows come in break order, each fact its own dt/dd.
311 const order = [...out.matchAll(/data-piece="([a-z-]+)" data-off="true"/g)].map((m) => m[1]);
312 expect(order.slice(0, 2)).toEqual(["plat-auth", "signup-gate"]);
313 expect(out).toContain("<dt>If it stops</dt>");
314 // Plain words, one colour: no badge, and neither "risk" nor "footing" on the page.
315 expect(out).not.toContain("badge");
316 expect(out.replace(/<[^>]*>/g, "")).not.toMatch(/\brisk|footing/i);
317 expect(out).toContain('data-machine="pg-grand"');
318 expect(out).toContain("forge:&lt;version&gt;");
319 expect(out).toContain("The forge runs on <span class=\"mono\">vxrail-1</span>.");
320 const hostile = { ...f, forgeNode: "<img src=x>" };
321 expect(String(footingSection(hostile))).not.toContain("<img src=x>");
322 });
323
324 test("the owner's /system shows it", async () => {
325 const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${tok["alice"]}` } });
326 expect(res.status).toBe(200);
327 const body = await res.text();
328 expect(body).toContain("<h2>What the forge stands on</h2>");
329 expect(body).toContain('id="sys-footing"');
330 });
331
332 test("/system keeps the rows behind one closed disclosure and never waits on the cluster", async () => {
333 const t0 = performance.now();
334 const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${tok["alice"]}` } });
335 const body = await res.text();
336 expect(performance.now() - t0).toBeLessThan(800);
337 const section = body.slice(body.indexOf('id="sys-footing"'), body.indexOf("</section>", body.indexOf('id="sys-footing"')));
338 expect(section).toMatch(/<details id="sys-footing-more" class="disclosure">/);
339 expect(section).not.toMatch(/<details[^>]*\bopen\b/);
340 expect(section.slice(section.indexOf("<details")).match(/data-piece=/g)?.length).toBe(FOOTING.length);
341 });
342
343 const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user });
344
345 test("the terminal screen gives an admin the rows off the forge's machine in break order, with node and if-dies", async () => {
346 const f = await probeFooting({ fetch: fakeKube().fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" });
347 const lines = footingLines(new Session(app, identity(getUserByName(app.db, "alice")!)), f);
348 expect(lines[0]).toBe("The forge runs on vxrail-1.");
349 const off = f.rows.filter((r) => r.risk);
350 expect(lines).toContain(`${off.length} of ${f.rows.length} pieces run on another machine. With pg-grand off, they fail in this order:`);
351 const steps = lines.filter((l) => /^ {0,2}\d+ {2}(answer|sign-in|build|release|backup)\b/.test(l));
352 expect(steps.length).toBe(off.length);
353 expect(steps[0]).toMatch(/^ {2}2 {2}sign-in\s+Sign-in edge/);
354 expect(steps.map((l) => Number(l.trim().split(/\s+/)[0]))).toEqual([...steps.map((l) => Number(l.trim().split(/\s+/)[0]))].sort((a, b) => a - b));
355 expect(lines).toContain(`${" ".repeat(15)}if it dies: Every request through the edge answers 500, /healthz too.`);
356 expect(lines).toContain(`${" ".repeat(15)}on pg-grand (declared: forge-release-tick is a systemd unit on a host; the cluster cannot show it)`);
357 expect(lines.at(-1)).toStartWith("On the forge's own machine: forge, tunnel, tunnel-home, traefik, hosts");
358 });
359
360 test("a guest, a signed-in non-admin and nobody get no lines and no door", () => {
361 const f = declaredFooting("x");
362 const doors = (user?: User): string[] => {
363 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(80, 24), exitHooks: false });
364 const ids = forgeCommands(session)(tui.ctx).map((c) => c.id);
365 tui.stop?.();
366 return ids;
367 };
368 const alice = getUserByName(app.db, "alice")!;
369 expect(doors(alice)).toContain("footing");
370 for (const who of [getUserByName(app.db, "bob")!, undefined]) {
371 expect(footingLines(new Session(app, identity(who)), f)).toEqual([]);
372 expect(doors(who)).not.toContain("footing");
373 }
374 });
375
376 test("the screen draws for an admin without waiting on the cluster", async () => {
377 const { app: tui, session } = createForgeApp(app, identity(getUserByName(app.db, "alice")!), { terminal: headlessTerminal(120, 50), exitHooks: false });
378 tui.start();
379 tui.ctx.push(footingScreen(session, tui.ctx));
380 await settleApp(tui);
381 const text = tui.screen.toText();
382 expect(text).toContain("What the forge stands on");
383 expect(text).toContain("pieces run on another machine");
384 tui.stop?.();
385 });
386
387 const mcp = async (who: string) => {
388 const res = await fetch(`${servers.url}/mcp`, {
389 method: "POST",
390 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok[who]}` },
391 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: { kind: "footing" } } }),
392 });
393 return (await res.json()) as any;
394 };
395
396 test("the MCP system tool answers it to an admin, with the risk lines, and refuses anyone else", async () => {
397 const out = JSON.parse((await mcp("alice")).result.content[0].text);
398 expect(out.rows.map((r: { id: string }) => r.id)).toEqual(FOOTING.map((p) => p.id));
399 expect(Array.isArray(out.risks)).toBe(true);
400 expect(out.risks.length).toBe(out.rows.filter((r: { risk: boolean }) => r.risk).length);
401 const refused = await mcp("bob");
402 expect(JSON.stringify(refused)).toContain("only an admin of this forge reads what it stands on");
403 });
404 });
test/mcp-system-routes.test.ts +2
@@ -47,6 +47,8 @@ const call = async (token: string) => {
4747 };
4848
4949 test("routes lists the over-budget route first, with its budget and slowest phase; a non-admin is refused", async () => {
50 // perfRecord is the process's own, and a shard-mate's requests leave samples (and patterns, up to the cap) in it.
51 perfRecord.clear();
5052 const at = Date.now();
5153 for (let i = 0; i < 25; i++) {
5254 perfRecord.add("/boards", { at, ms: 100, signedIn: true, phases: [{ name: "app", ms: 100, hit: false }] });
test/palette-roster.test.ts +1
@@ -118,6 +118,7 @@ test("a site-admin person is offered the whole roster", () => {
118118 ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"],
119119 ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"],
120120 ["stalls", "Main-thread stalls", "the last hour's stall ms by route"],
121 ["footing", "What the forge stands on", "each piece on another machine, in the order it would break the forge"],
121122 ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"],
122123 ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"],
123124 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
test/perf-aggregate.test.ts +10
@@ -16,6 +16,16 @@ test("memory is capped", () => {
1616 for (let i = 0; i < MAX_SAMPLES + 50; i++) r.add("/x", { at, ms: 1, signedIn: true, phases: [] });
1717 expect(r.traffic("/x", 0).length).toBeLessThanOrEqual(MAX_SAMPLES);
1818 });
19 test("clear forgets every pattern's samples and first sight, and the record fills again", () => {
20 const r = createPerfRecord();
21 r.add("/x", { at, ms: 1, signedIn: true, phases: [] });
22 r.clear();
23 expect(r.patterns()).toEqual([]);
24 expect(r.traffic("/x", 0)).toEqual([]);
25 expect(r.first("/x")).toBeUndefined();
26 r.add("/x", { at: at + 1, ms: 2, signedIn: true, phases: [] });
27 expect(r.first("/x")).toBe(at + 1);
28 });
1929 test("a Server-Timing header becomes phases, hits marked", () => {
2030 expect(parseServerTiming('app;dur=5.0, kpis;dur=4400.0;desc="hit"')).toEqual([
2131 { name: "app", ms: 5, hit: false },
test/system-parts.test.ts +1
@@ -52,6 +52,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => {
5252 "Machine",
5353 "Disks and network",
5454 "Processes",
55 "What the forge stands on",
5556 "Loop lag, last 20 minutes",
5657 "Main-thread stalls, last hour",
5758 "Telemetry readers",
test/timers-named.test.ts +1
@@ -124,6 +124,7 @@ const ALLOWED: readonly Allowed[] = [
124124 ["src/snapshot/drill.ts", "() => reject("],
125125 ["src/snapshot/store.ts", "() => reject("],
126126 ["src/snapshot/lanes.ts", "() => reject("],
127 ["src/system/footing.ts", "() => r(null)"],
127128 ["src/release/devtools.ts", "() => { if (pending.delete(n))"],
128129 ["src/release/devtools.ts", "() => { waiting.set(event"],
129130 ["src/release/prove.ts", "() => resolve(\"hung\")"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs