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

A forge runs anywhere: a stated footprint, proven on a 1-CPU, 512 MiB machine #12236

Merged opened this

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

Merged

mason/forge:mason/12092-a-forge-runs-anywhere-a-stated-footprint → main 19 commits fork 5 threads · 5 unresolved

  • Merged. mason/12092-a-forge-runs-anywhere-a-stated-footprint was merged into main as 78db48e.

Review

Sign in to review this change.
12 files changed +355 −7
docs/design/attention-budget.json +5 −5
@@ -173,16 +173,16 @@
173173 },
174174 "system": {
175175 "390": {
176 "words": 190,
177 "blocks": 69,
176 "words": 212,
177 "blocks": 71,
178178 "colours": 0
179179 },
180180 "1280": {
181 "words": 190,
182 "blocks": 69,
181 "words": 212,
182 "blocks": 71,
183183 "colours": 0
184184 },
185 "note": "Measured on the test forge's empty state with the machine reading replaced by an empty one and the two cell-cap sentences fixed (the live page's counts depend on the host's cores, disks, processes and cgroups); nothing seeded or waited for. Blocks 65 -> 67 (#11805): the off-site backup section's heading and one-sentence state stay on first view so a red lane is seen without a click; its rows sit behind the disclosure. Blocks 67 -> 69 (#12010): the database size section's heading and its one-sentence state (the file's size and growth, a badge when either is worth a look) stay on first view, as the off-site lane's do; its fact rows and the table of the ten largest tables and indexes sit behind the disclosure."
185 "note": "Measured on the test forge's empty state with the machine reading replaced by an empty one and the two cell-cap sentences fixed (the live page's counts depend on the host's cores, disks, processes and cgroups); nothing seeded or waited for. Blocks 65 -> 67 (#11805): the off-site backup section's heading and one-sentence state stay on first view so a red lane is seen without a click; its rows sit behind the disclosure. Blocks 67 -> 69 (#12010): the database size section's heading and its one-sentence state (the file's size and growth, a badge when either is worth a look) stay on first view, as the off-site lane's do; its fact rows and the table of the ten largest tables and indexes sit behind the disclosure. Words +22, blocks +1 (#12092): the footprint section's heading and its one-sentence state (on an empty forge, not read yet and the memory budget) stay on first view; the measured facts and the optional parts sit behind the disclosure."
186186 },
187187 "explore": {
188188 "390": {
docs/features/footprint.md added +26
@@ -0,0 +1,26 @@
1 # What a forge costs (#12092)
2
3 A forge is one process and one thread. Pages and git need nothing else: Chromium (looks) and the crew's AI tool are optional, and `/system` ("What the forge costs") says which are off and why.
4
5 The stated budget (`BUDGET` in `src/system/footprint.ts`) for a 1-CPU, 512 MiB machine: 256 MiB resident at rest, the first page within 5 s of start, and an empty forge's data under 64 MiB. A reading over it says "Over budget", never hidden.
outdated

The terminal sentence is in the System dialog after the database lines, not 'under the forge line'. Correct it and say how to reach the full view.

6
7 Where it shows:
8
9 - `/system`, section "What the forge costs" (admins and granted telemetry readers, as the page's other sections). First view is one sentence: "Within budget: the forge is using 170 MiB; it should stay under 256 MiB.", or what is over. Open its disclosure for the measured facts (memory, first page, data on disk, machine and architecture) and one row per optional part, on or off and why.
10 - The terminal: palette → System opens the System dialog; after the database lines it says the same sentence, then `off:` and the optional parts that are off. The dialog does not scroll, so the facts and the reasons are on `/system` only.
11
12 The release proof reads the footprint once the new version answers `/healthz`, before the smoke checks load it, and keeps it in `release/proof.json` (`footprint`). `/system` shows this process's last reading, else the proof's.
13
14 Reading the disk figure walks the whole data directory (`treeBytes`), once per release proof and never on a page view; on a big data directory that walk can take seconds. It is asynchronous, so it does not hold the event loop.
15
16 This is the measurement step. The work that cuts the footprint is tracked in its own issues, each blocking #12092: the cell ceiling on a small pod (#12346), reading the pod's limit in `/system` and health (#12347), a launcher-free start (#12348), lazy-loading what a small forge never uses (#12349), an ARM image (#12350), and the serve/work split only on machines with cores (#12351).
17
18 ## Measured sizes (2026-11-13, release image, same first-hour journey)
19
20 | limit | boot to first page | RSS at rest / after | result |
21 |---|---|---|---|
22 | 2 CPU / 2 GiB | 3.5 s | 172 / 396 MiB | passes |
23 | 1 CPU / 1 GiB | 5.3 s | 175 / 356 MiB | passes |
24 | 1 CPU / 512 MiB | 5.8 s | 170 / 382 MiB | passes, barely |
25 | 0.5 CPU / 384 MiB | 10–13 s | 167 / killed | the forge was OOM-killed by its own cell (#12346) |
26 | 0.25 CPU / 256 MiB | 25 s | 168 / 202 MiB | passes |
scripts/unused-exports-allow.json +1
@@ -163,6 +163,7 @@
163163 "src/story/records.ts isKnownRecord",
164164 "src/story/records.ts parseRecords",
165165 "src/system/flow_cache.ts forgetFlow",
166 "src/system/footprint.ts clearReadyMark",
166167 "src/system/live.ts useLive",
167168 "src/system/telemetry_reads.ts resetTelemetryReads",
168169 "src/tui/gallery.ts galleryFrame",
src/main.ts +3
@@ -101,6 +101,7 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts";
101101 import { running } from "./kernel/version.ts";
102102 import { record as recordDegraded } from "./kernel/degraded_history.ts";
103103 import { vitals } from "./kernel/vitals.ts";
104 import { markReady, readFootprint, keepFootprint } from "./system/footprint.ts";
104105 import { proveRunning } from "./release/prove.ts";
105106 import { walkJourney } from "./release/journey.ts";
106107 import { proberKey } from "./release/prober.ts";
@@ -418,6 +419,7 @@ await warmFlow(app);
418419 await warmPlans(app);
419420 await warmDisplays(app).catch(() => undefined);
420421 const servers = await serve(app);
422 markReady();
421423 // From here the flow's forge-wide readings are read once and shared for half a minute
422424 // (`flow/kept.ts`).
423425 keepFlowReadings(KEPT_MS);
@@ -817,6 +819,7 @@ proveRunning({
817819 url: forgeUrl,
818820 token: process.env["FORGE_TOKEN"],
819821 journey: walkJourney,
822 footprint: async () => keepFootprint(await readFootprint(app.config.dataDir)),
820823 proberKey: proberKey(app.hookSecret),
821824 // The first app's production host: it must never answer as the forge (#6726).
822825 appHost: (() => {
src/release/prove.ts +9
@@ -28,6 +28,7 @@ import { UNSTAMPED } from "../kernel/version.ts";
2828 import { ownProbeHeaders } from "../kernel/probe.ts";
2929 import type { Log } from "../kernel/log.ts";
3030 import { during } from "../perf/jobs.ts";
31 import type { Footprint } from "../system/footprint.ts";
3132
3233 /** How long the public URL has to answer as the new version. */
3334 export const GIVE_UP_MS = 10 * 60_000;
@@ -61,6 +62,8 @@ export interface Proof {
6162 readonly gaveUp?: boolean;
6263 /** A stranger's journey walked on the public URL after the smoke checks (#11158); its timings, in ms, for the release card. */
6364 readonly journey?: { readonly readyMs: number | null; readonly firstActivityMs: number | null; readonly screens: readonly string[] };
outdated

This comment splits the journey field from its doc comment above (line 63). Place the footprint field before it or after journey.

65 /** What the forge cost at rest when this version first answered, before any load (#12092). */
66 readonly footprint?: Footprint;
6467 /** A person asked for it to be proven again now: the next tick does, whatever the backoff says. */
6568 readonly retryNow?: boolean;
6669 }
@@ -237,6 +240,8 @@ export interface ProveOptions {
237240 readonly signal?: AbortSignal;
238241 /** Walks the stranger's journey after the smoke checks; the forge passes `walkJourney`, and a test its own. Without it no walk runs. */
239242 readonly journey?: typeof walkJourney;
243 /** Reads the footprint; the proof records it as soon as the version answers, before any load. */
244 readonly footprint?: () => Promise<Footprint>;
240245 /**
241246 * This boot's prober key (`release/prober.ts`), handed to the journey so the forge's rate
242247 * limits know it.
@@ -266,6 +271,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
266271 const baseFetch = o.fetch ?? fetch;
267272 const doFetch = ((url, init) => baseFetch(url, o.signal ? { ...init, signal: init?.signal ? AbortSignal.any([init.signal, o.signal]) : o.signal } : init)) as typeof fetch;
268273 const failedBefore = last?.version === o.version && !last.ok ? last : undefined;
274 let measured: Footprint | undefined;
269275 const finish = (failure?: string, slow = false, gaveUp = false, walked?: JourneyResult): Proof => {
270276 if (o.signal?.aborted) throw new Error("the proof was aborted as hung");
271277 const at = new Date(now()).toISOString();
@@ -273,6 +279,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
273279 version: o.version,
274280 at,
275281 ok: failure === undefined,
282 ...(measured === undefined ? {} : { footprint: measured }),
276283 ...(walked === undefined ? {} : { journey: { readyMs: walked.readyMs, firstActivityMs: walked.firstActivityMs, screens: walked.screens } }),
277284 ...(failure === undefined
278285 ? {}
@@ -304,6 +311,8 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined>
304311 }
305312 await sleep(POLL_MS);
306313 }
314 // At rest: the version answers and nothing has loaded it yet.
315 measured = await o.footprint?.().catch(() => undefined);
307316 const failures = await smoke(o.url, o.token ?? "", {
308317 fetch: doFetch,
309318 anonymousBoard: true,
src/system/footprint.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * What a forge costs (#12092): memory at rest, disk, time from start to the
3 * first page, the machine it stands on, and what is switched off because a
4 * small machine cannot afford it. The budget below is stated so growth past
5 * it shows. The release proof records a reading (`release/prove.ts`) and
6 * /system shows the last one.
7 */
8 import { arch, availableParallelism } from "node:os";
9 import { memoryLimitBytes } from "../core/capacity.ts";
10 import { findChromium } from "../looks/capture.ts";
11 import { treeBytes } from "./largest_dirs.ts";
12
13 const MIB = 1024 * 1024;
14
15 /**
16 * What a forge may cost on a 1-CPU, 512 MiB machine; a reading over it is
17 * shown as over, never hidden.
18 */
19 export const BUDGET = { restBytes: 256 * MIB, readyMs: 5_000, emptyDiskBytes: 64 * MIB } as const;
20
21 /** One part of the forge that is optional, whether it is on, and why in words. */
22 export interface Optional {
23 readonly name: string;
24 readonly on: boolean;
25 readonly why: string;
26 }
27
28 /**
29 * One reading of the footprint; every number is measured, none guessed. A forge that
30 * never reported ready is not within budget.
31 */
32 export interface Footprint {
33 readonly at: string;
34 readonly arch: string;
35 readonly cpus: number;
36 readonly machineBytes: number;
37 /** Resident memory of this process, the one that serves pages and git. */
38 readonly rssBytes: number;
39 readonly heapBytes: number;
40 /** Seconds this process has run when read. */
41 readonly ageSeconds: number;
42 /** Start to the listener answering, in ms; null until it is up. */
43 readonly readyMs: number | null;
44 /** Bytes of the data directory on disk; null when it could not be walked. */
45 readonly diskBytes: number | null;
46 readonly optional: readonly Optional[];
47 readonly withinBudget: boolean;
48 }
49
50 /** Start to the listener answering, in ms, once `markReady` has said so; null before. */
51 let readyMs: number | null = null;
52
53 /** Called once the listener is up: the time since this process started, kept for every reading. */
54 export function markReady(uptimeSeconds: number = process.uptime()): void {
55 if (readyMs === null) readyMs = Math.round(uptimeSeconds * 1000);
56 }
57
58 /** Forgets the ready mark, so a test can read a forge that has not reported ready. */
59 export function clearReadyMark(): void {
60 readyMs = null;
61 }
62
63 /** The optional parts and whether each is on. `chromium` and `crewTool` are a test's to hand in. */
64 export function optionals(
65 chromium: string | null = findChromium() ?? null,
66 crewTool: string | null = Bun.which(process.env["FORGE_CLAUDE"] ?? "claude"),
67 ): Optional[] {
68 return [
69 {
70 name: "Chromium (looks)",
71 on: chromium !== null,
72 why: chromium === null ? "no Chromium on this machine, so pages are served without pictures; pages and git do not need it" : "found, and started only for a picture, one at a time",
73 },
74 {
75 name: "Crew AI tool",
76 on: crewTool !== null,
77 why: crewTool === null ? "Claude Code is not on PATH or at FORGE_CLAUDE, so assigned work waits; pages and git do not need it" : "found, and run only while a crew run is working",
78 },
79 ];
80 }
81
82 /** Reads the footprint now; the data directory's walk is asynchronous, off the loop. */
83 export async function readFootprint(dataDir: string, now: () => number = Date.now): Promise<Footprint> {
84 const mem = process.memoryUsage();
85 const cpus = availableParallelism();
86 const machineBytes = memoryLimitBytes();
87 const diskBytes = await treeBytes(dataDir).catch(() => null);
88 return {
89 at: new Date(now()).toISOString(),
90 arch: arch(),
91 cpus,
92 machineBytes,
93 rssBytes: mem.rss,
94 heapBytes: mem.heapUsed,
95 ageSeconds: Math.round(process.uptime()),
96 readyMs,
97 diskBytes,

Non-blocking: "the proof and the page both write it" is wrong; only the proof (via main.ts) calls keepFootprint. Reword.

98 optional: optionals(),
99 withinBudget: mem.rss <= BUDGET.restBytes && readyMs !== null && readyMs <= BUDGET.readyMs,
100 };
101 }
102
103 /** The last reading kept in this process, for /system; the proof and the page both write it. */
104 let last: Footprint | null = null;
105 export const keepFootprint = (f: Footprint): Footprint => (last = f);
106 export const lastFootprint = (): Footprint | null => last;
107
108 const mib = (n: number): string => `${(n / MIB).toFixed(n < 10 * MIB ? 1 : 0)} MiB`;
109 const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)} s`;
110
111 /**
112 * The verdict in a person's words, as /system and the terminal both say it: what is over,
113 * else the memory beside its budget.
114 */
115 export function footprintVerdict(f: Footprint): string {
116 const memory = `the forge is using ${mib(f.rssBytes)}; it should stay under ${mib(BUDGET.restBytes)}.`;
117 if (f.rssBytes > BUDGET.restBytes) return `Over budget: ${memory}`;
118 if (f.readyMs === null) return "Over budget: the forge has not said its first page is up yet.";
119 if (f.readyMs > BUDGET.readyMs) return `Over budget: the first page came ${seconds(f.readyMs)} after start; it should come within ${seconds(BUDGET.readyMs)}.`;
120 return `Within budget: ${memory}`;
121 }
122
123 /** One measured fact behind the verdict: a label and its value in words. */
124 export interface Fact {
125 readonly label: string;
126 readonly value: string;
127 }
128
129 /**
130 * The rest of the reading, one fact a row, for the page's disclosure and the terminal's full view.
131 */
132 export function footprintFacts(f: Footprint): Fact[] {
133 return [
134 { label: "Memory", value: `${mib(f.rssBytes)} after ${f.ageSeconds} s running; budget ${mib(BUDGET.restBytes)}` },
135 { label: "First page", value: f.readyMs === null ? "not up yet" : `${seconds(f.readyMs)} after start; budget ${seconds(BUDGET.readyMs)}` },
136 { label: "Data on disk", value: f.diskBytes === null ? "not read" : `${mib(f.diskBytes)}; an empty forge's budget ${mib(BUDGET.emptyDiskBytes)}` },
137 { label: "Machine", value: `${f.arch}, ${f.cpus} CPU, ${mib(f.machineBytes)}` },
138 ];
139 }
src/system/largest_dirs.ts +6
@@ -134,3 +134,9 @@ export function largestDirsCache(
134134 }
135135
136136 export const largestDirs = largestDirsCache();
137
138 /** Everything under the data directory on its own volume, as `du -x` counts it; asynchronous. */
139 export async function treeBytes(dataDir: string): Promise<number> {
140 const root = await lstat(dataDir);
141 return onDisk(root) + (await sizeOf(dataDir, root.dev));
142 }
src/tui/screens/system.ts +20
@@ -26,6 +26,8 @@
2626 * Under them, the database's size (#12010, `dbSizeLines`): the file, its
2727 * WAL and growth, then its largest tables and indexes, through the same
2828 * `dbSize` cache and gate as /system and the MCP tool, never waited on.
29 * Then what the forge costs (#12092, `footprintLines`): the budget verdict and
30 * what is off, from the last reading, gated as the rest.
2931 */
3032
3133 import type { Ctx, Screen } from "../app/app.ts";
@@ -45,6 +47,8 @@ import { dialog } from "../ui/widgets.ts";
4547 import { Scroll, ago, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts";
4648 import { largestDirs, type LargestDirs } from "../../system/largest_dirs.ts";
4749 import { dbSize, dbSizeNeed, type DbSize } from "../../system/db_size.ts";
50 import { footprintVerdict, lastFootprint } from "../../system/footprint.ts";
51 import { lastProof } from "../../release/prove.ts";
4852 import type { PaletteEntry } from "./entries.ts";
4953 import { readHealth } from "../../actions/health.ts";
5054 import { egressReportedLine } from "../../system/egress_line.ts";
@@ -180,6 +184,20 @@ export function largestLines(session: Session, found: LargestDirs | null, inner
180184 ];
181185 }
182186
187 /**
188 * What the forge costs, as /system's "What the forge costs" says it on first view
189 * (#12092): the verdict in words, then what is off. A few lines, since the System
190 * dialog does not scroll; the measured facts and why each part is off are behind
191 * the page's disclosure. Empty for anyone the page refuses.
192 */
193 export function footprintLines(session: Session, f = lastFootprint() ?? lastProof(session.app.config.dataDir)?.footprint ?? null, inner = WIDTH - 6): string[] {
194 if (!telemetryReader(session.app, session.user)) return [];
195 const head = "What the forge costs";
196 if (f === null) return [head, "not read yet; the release proof reads it once this version answers"];
197 const off = f.optional.filter((o) => !o.on).map((o) => o.name);
198 return [head, ...wrap(footprintVerdict(f), inner), ...(off.length === 0 ? [] : [truncate(`off: ${off.join(", ")}`, inner)])];
199 }
200
183201 /**
184202 * The database's size, as /system's "Database size" says it (#12010): the
185203 * file, its WAL, the room inside and its growth, then the largest tables
@@ -346,6 +364,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
346364 // The cache /system reads (#12010); a stale walk starts behind it,
347365 // and only for a reader.
348366 const db = reads ? dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log), true) : [];
367 const footprint = footprintLines(session);
349368 const buttons = dialog(s, area, ctx.theme.c, {
350369 title: "System",
351370 body: [
@@ -355,6 +374,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
355374 ...(largest.length === 0 ? [] : ["", ...largest]),
356375 // Under the largest directories with no blank between: the dialog does not scroll.
357376 ...db,
377 ...(footprint.length === 0 ? [] : ["", ...footprint]),
358378 ...(offered ? ["", ...readerLines(session)] : []),
359379 ...(features.length === 0 ? [] : ["", ...features]),
360380 ...(offered ? ["", grantLine(session), "g: grant or take back telemetry:read"] : []),
src/web/pages/system.ts +21 −1
@@ -49,6 +49,8 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts";
4949 import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts";
5050 import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts";
5151 import { egressReportedLine } from "../../system/egress_line.ts";
52 import { lastFootprint, footprintFacts, footprintVerdict, BUDGET, type Footprint } from "../../system/footprint.ts";
53 import { lastProof } from "../../release/prove.ts";
5254 import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords, type Footing, type Machine } from "../../system/footing.ts";
5355 import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts";
5456 import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts";
@@ -94,6 +96,9 @@ export function largestDirsSection(found: LargestDirs | null): Html {
9496 <p class="muted small">Measured ${time(found.at)}</p>`}</section>`;
9597 }
9698
99 /** One labelled fact in a `status-rows` list. */
100 const fact = (name: string, value: string): Html => html`<div class="status-row"><dt>${name}</dt><dd>${value}</dd></div>`;
101
97102 /**
98103 * The database's size (#12010). On first view, the heading and one sentence:
99104 * the file's size and its growth a day, with a badge when either is worth a
@@ -111,7 +116,6 @@ export function dbSizeSection(size: DbSize): Html {
111116 ? "growth needs a day of readings"
112117 : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`;
113118 const said = dbSizeNeed(size.fileBytes, g);
114 const fact = (name: string, value: string): Html => html`<div class="status-row"><dt>${name}</dt><dd>${value}</dd></div>`;
115119 const state = html`forge.db is ${bytes(size.fileBytes)}; ${grows}
116120 ${said === undefined ? null : badge(said.cause === "db-size" ? `past ${bytes(DB_FILE_CAP)}` : `over ${bytes(DB_GROWTH_MAX)} a day`, { tone: "attn" })}`;
117121 return html`<section class="settings-block" id="sys-db"><h2>Database size</h2>
@@ -311,6 +315,21 @@ export function footingSection(f: Footing): Html {
311315 ${(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>`;
312316 }
313317
318 /**
319 * What the forge costs (#12092): the last reading at rest (this process's, else
320 * the release proof's), the stated budget, and what is switched off and why.
321 */
322 export function footprintSection(f: Footprint | null): Html {
323 if (f === null)
324 return html`<section class="settings-block" id="sys-footprint"><h2>What the forge costs</h2>
325 <p class="small muted">Not read yet: the release proof reads it once this version answers. It should stay under ${Math.round(BUDGET.restBytes / 1048576)} MiB.</p></section>`;
326 return html`<section class="settings-block" id="sys-footprint" data-within="${String(f.withinBudget)}"><h2>What the forge costs</h2>
327 ${disclosure(footprintVerdict(f), html`<dl class="status-rows" id="sys-footprint-facts">${footprintFacts(f).map((x) => fact(x.label, x.value))}</dl>
328 <ul class="list" aria-label="Optional parts">${f.optional.map((o) =>
329 row({ title: o.name, facts: [{ label: o.on ? "On" : "Off", value: o.why }], data: { optional: o.name, on: String(o.on) } }),
330 )}</ul>`, { id: "sys-footprint-more" })}</section>`;
331 }
332
314333 /**
315334 * The off-site backup lane (#11805): where the forge's own mirror pass
316335 * writes, whether it seals what it writes, when it last succeeded, and the
@@ -564,6 +583,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
564583 <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>
565584 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
566585 <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section>
586 ${footprintSection(lastFootprint() ?? lastProof(ctx.app.config.dataDir)?.footprint ?? null)}
567587 ${footingSection(standsOn)}
568588 ${offsiteSection(timePhase(ctx.request, "offsite", () => readOffsiteLane(ctx.app.config.dataDir, ctx.app.db)))}
569589 ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))}
test/footprint.test.ts added +95
@@ -0,0 +1,95 @@
1 import { afterEach, expect, test } from "bun:test";
2 import { mkdtempSync, writeFileSync } from "node:fs";
3 import { join } from "node:path";
4 import { tmpdir } from "node:os";
5 import { BUDGET, clearReadyMark, footprintFacts, footprintVerdict, markReady, optionals, readFootprint, type Footprint } from "../src/system/footprint.ts";
6 import { footprintSection } from "../src/web/pages/system.ts";
7 import { proveRelease, lastProof } from "../src/release/prove.ts";
8
9 const MIB = 1024 * 1024;
10 const dataDir = (): string => mkdtempSync(join(tmpdir(), "fp-"));
11 const reading = (over: Partial<Footprint> = {}): Footprint => ({
12 at: "2026-11-14T00:00:00Z",
13 arch: "x64",
14 cpus: 1,
15 machineBytes: 512 * MIB,
16 rssBytes: 170 * MIB,
17 heapBytes: 40 * MIB,
18 ageSeconds: 60,
19 readyMs: 2000,
20 diskBytes: 42 * MIB,
21 optional: optionals(null, null),
22 withinBudget: true,
23 ...over,
24 });
25
26 afterEach(clearReadyMark);
27
28 test("a small machine says what is off and why", () => {
29 const o = optionals(null, null);
30 expect(o.map((x) => [x.name, x.on])).toEqual([["Chromium (looks)", false], ["Crew AI tool", false]]);
31 expect(o[0]!.why).toContain("pages and git do not need it");
32 expect(optionals("/usr/bin/chromium", "/usr/bin/claude").every((x) => x.on)).toBe(true);
33 });
34
35 test("a reading is measured: memory and the data directory on disk", async () => {
36 const dir = dataDir();
37 writeFileSync(join(dir, "a"), "x".repeat(10_000));
38 const f = await readFootprint(dir);
39 expect(f.rssBytes).toBeGreaterThan(0);
40 expect(f.diskBytes).toBeGreaterThanOrEqual(10_000);
41 expect((await readFootprint(join(dir, "missing"))).diskBytes).toBeNull();
42 });
43
44 test("a forge that never reported ready is not within budget; once marked, the mark is kept", async () => {
45 const dir = dataDir();
46 const before = await readFootprint(dir);
47 expect(before.readyMs).toBeNull();
48 expect(before.withinBudget).toBe(false);
49 markReady(1);
50 markReady(9);
51 const after = await readFootprint(dir);
52 expect(after.readyMs).toBe(1000);
53 expect(after.withinBudget).toBe(after.rssBytes <= BUDGET.restBytes);
54 });
55
56 test("a first page later than the budget is over it", async () => {
57 markReady(BUDGET.readyMs / 1000 + 1);
58 expect((await readFootprint(dataDir())).withinBudget).toBe(false);
59 });
60
61 test("the verdict is in plain words, saying what is over", () => {
62 expect(footprintVerdict(reading())).toBe("Within budget: the forge is using 170 MiB; it should stay under 256 MiB.");
63 expect(footprintVerdict(reading({ rssBytes: 300 * MIB, withinBudget: false }))).toBe("Over budget: the forge is using 300 MiB; it should stay under 256 MiB.");
64 expect(footprintVerdict(reading({ readyMs: 6500, withinBudget: false }))).toBe("Over budget: the first page came 6.5 s after start; it should come within 5.0 s.");
65 expect(footprintVerdict(reading({ readyMs: null, withinBudget: false }))).toStartWith("Over budget:");
66 });
67
outdated

Asserts on the source text of main.ts instead of behaviour; remove and test markReady/readFootprint directly.

68 test("the facts behind the verdict name disk, first page and the machine", () => {
69 const facts = Object.fromEntries(footprintFacts(reading()).map((x) => [x.label, x.value]));
70 expect(facts["Data on disk"]).toStartWith("42 MiB");
71 expect(facts["First page"]).toStartWith("2.0 s after start");
72 expect(facts["Machine"]).toBe("x64, 1 CPU, 512 MiB");
73 expect(footprintFacts(reading({ diskBytes: null, readyMs: null })).map((x) => x.value)).toContain("not read");
74 });
75
76 test("the page says the verdict on first view, with no badge; the facts and parts sit behind the disclosure", () => {
77 const html = String(footprintSection(reading({ rssBytes: 300 * MIB, withinBudget: false })));
78 const summary = html.slice(html.indexOf("<summary"), html.indexOf("</summary>"));
79 expect(summary).toContain("Over budget: the forge is using 300 MiB; it should stay under 256 MiB.");
80 expect(summary).not.toContain("badge");
81 expect(summary).not.toContain("x64");
82 const behind = html.slice(html.indexOf("</summary>"));
83 expect(behind).toContain("<dt>Data on disk</dt>");
84 expect(behind).toContain("<dt>Machine</dt><dd>x64, 1 CPU, 512 MiB</dd>");
85 expect(behind).toContain("Chromium (looks)");
86 expect(String(footprintSection(null))).toContain("Not read yet");
87 });
88
89 test("the release proof records the footprint it is handed once the version answers", async () => {
90 const dir = dataDir();
91 const fp = reading({ rssBytes: 123 * MIB });
92 const fetcher = (async () => new Response("ok", { headers: { "x-forge-version": "1.0.0" } })) as unknown as typeof fetch;
93 await proveRelease({ dataDir: dir, version: "1.0.0", url: "http://x.test", fetch: fetcher, giveUpMs: 1, sleep: async () => {}, footprint: async () => fp });
94 expect(lastProof(dir)?.footprint?.rssBytes).toBe(123 * MIB);
95 });
test/system-parts.test.ts +1
@@ -54,6 +54,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => {
5454 "Largest under the forge's data",
5555 "Database size",
5656 "Processes",
57 "What the forge costs",
5758 "What the forge stands on",
5859 "Off-site backup",
5960 "Loop lag, last 20 minutes",
test/tui-system.test.ts +29 −1
@@ -16,10 +16,11 @@ import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1616 import { setTelemetryGrant } from "../src/actions/telemetry.ts";
1717 import { hasGrant } from "../src/core/telemetry.ts";
1818 import { countRead, resetTelemetryReads } from "../src/system/telemetry_reads.ts";
19 import { dbSizeLines, dbSizeScreen, forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
19 import { dbSizeLines, dbSizeScreen, footprintLines, forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
2020 import type { DbSize } from "../src/system/db_size.ts";
2121 import { changeCapacity } from "../src/actions/capacity.ts";
2222 import { EGRESS_REPORTED } from "../src/system/egress_line.ts";
23 import { keepFootprint } from "../src/system/footprint.ts";
2324 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2425 import { quietLogs } from "./helpers/globals.ts";
2526 import { settleApp } from "./helpers/tui.ts";
@@ -429,3 +430,30 @@ describe("tui system telemetry readers (#9069)", () => {
429430 resetTelemetryReads();
430431 });
431432 });
433
434 describe("tui system footprint (#12092)", () => {
435 const fp = { at: "2026-11-14T00:00:00Z", arch: "x64", cpus: 1, machineBytes: 512 * MiB, rssBytes: 300 * MiB, heapBytes: 50 * MiB, ageSeconds: 60, readyMs: 2000, diskBytes: 5 * MiB, optional: [{ name: "Chromium (looks)", on: false, why: "none here" }, { name: "Crew AI tool", on: true, why: "found" }], withinBudget: false };
436
437 test("an admin reads the verdict and what is off; a member reads nothing", () => {
438 const lines = footprintLines(new Session(app, identity(carol)), fp);
439 expect(lines[0]).toBe("What the forge costs");
440 expect(lines.join(" ").replace(/\s+/g, " ")).toContain("Over budget: the forge is using 300 MiB; it should stay under 256 MiB.");
441 expect(lines.at(-1)).toBe("off: Chromium (looks)");
442 expect(footprintLines(new Session(app, identity(bob)), fp)).toEqual([]);
443 expect(footprintLines(new Session(app, identity(carol)), null)[1]).toContain("not read yet");
444 });
445
446 test("the System dialog carries the verdict and the off line", async () => {
447 keepFootprint(fp);
448 const terminal = headlessTerminal(120, 60);
449 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false });
450 tui.start();
451 tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session));
452 await settleApp(tui);
453 const seen = tui.screen.toText();
454 expect(seen).toContain("What the forge costs");
455 expect(seen).toContain("Over budget: the forge is using 300 MiB");
456 expect(seen).toContain("off: Chromium (looks)");
457 tui.stop?.();
458 });
459 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs