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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12092-a-forge-runs-anywhere-a-stated-footprint → main 19 commits fork 5 threads · 5 unresolved
12 files
changed +355
−7
docs/design/attention-budget.json +5 −5
| @@ -173,16 +173,16 @@ | ||
| 173 | 173 | }, |
| 174 | 174 | "system": { |
| 175 | 175 | "390": { |
| 176 | "words": 190, | |
| 177 | "blocks": 69, | |
| 176 | "words": 212, | |
| 177 | "blocks": 71, | |
| 178 | 178 | "colours": 0 |
| 179 | 179 | }, |
| 180 | 180 | "1280": { |
| 181 | "words": 190, | |
| 182 | "blocks": 69, | |
| 181 | "words": 212, | |
| 182 | "blocks": 71, | |
| 183 | 183 | "colours": 0 |
| 184 | 184 | }, |
| 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." | |
| 186 | 186 | }, |
| 187 | 187 | "explore": { |
| 188 | 188 | "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. | |
| 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 @@ | ||
| 163 | 163 | "src/story/records.ts isKnownRecord", |
| 164 | 164 | "src/story/records.ts parseRecords", |
| 165 | 165 | "src/system/flow_cache.ts forgetFlow", |
| 166 | "src/system/footprint.ts clearReadyMark", | |
| 166 | 167 | "src/system/live.ts useLive", |
| 167 | 168 | "src/system/telemetry_reads.ts resetTelemetryReads", |
| 168 | 169 | "src/tui/gallery.ts galleryFrame", |
src/main.ts +3
| @@ -101,6 +101,7 @@ import { configProblem, dataDirOf, preflight } from "./kernel/preflight.ts"; | ||
| 101 | 101 | import { running } from "./kernel/version.ts"; |
| 102 | 102 | import { record as recordDegraded } from "./kernel/degraded_history.ts"; |
| 103 | 103 | import { vitals } from "./kernel/vitals.ts"; |
| 104 | import { markReady, readFootprint, keepFootprint } from "./system/footprint.ts"; | |
| 104 | 105 | import { proveRunning } from "./release/prove.ts"; |
| 105 | 106 | import { walkJourney } from "./release/journey.ts"; |
| 106 | 107 | import { proberKey } from "./release/prober.ts"; |
| @@ -418,6 +419,7 @@ await warmFlow(app); | ||
| 418 | 419 | await warmPlans(app); |
| 419 | 420 | await warmDisplays(app).catch(() => undefined); |
| 420 | 421 | const servers = await serve(app); |
| 422 | markReady(); | |
| 421 | 423 | // From here the flow's forge-wide readings are read once and shared for half a minute |
| 422 | 424 | // (`flow/kept.ts`). |
| 423 | 425 | keepFlowReadings(KEPT_MS); |
| @@ -817,6 +819,7 @@ proveRunning({ | ||
| 817 | 819 | url: forgeUrl, |
| 818 | 820 | token: process.env["FORGE_TOKEN"], |
| 819 | 821 | journey: walkJourney, |
| 822 | footprint: async () => keepFootprint(await readFootprint(app.config.dataDir)), | |
| 820 | 823 | proberKey: proberKey(app.hookSecret), |
| 821 | 824 | // The first app's production host: it must never answer as the forge (#6726). |
| 822 | 825 | appHost: (() => { |
src/release/prove.ts +9
| @@ -28,6 +28,7 @@ import { UNSTAMPED } from "../kernel/version.ts"; | ||
| 28 | 28 | import { ownProbeHeaders } from "../kernel/probe.ts"; |
| 29 | 29 | import type { Log } from "../kernel/log.ts"; |
| 30 | 30 | import { during } from "../perf/jobs.ts"; |
| 31 | import type { Footprint } from "../system/footprint.ts"; | |
| 31 | 32 | |
| 32 | 33 | /** How long the public URL has to answer as the new version. */ |
| 33 | 34 | export const GIVE_UP_MS = 10 * 60_000; |
| @@ -61,6 +62,8 @@ export interface Proof { | ||
| 61 | 62 | readonly gaveUp?: boolean; |
| 62 | 63 | /** A stranger's journey walked on the public URL after the smoke checks (#11158); its timings, in ms, for the release card. */ |
| 63 | 64 | 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; | |
| 64 | 67 | /** A person asked for it to be proven again now: the next tick does, whatever the backoff says. */ |
| 65 | 68 | readonly retryNow?: boolean; |
| 66 | 69 | } |
| @@ -237,6 +240,8 @@ export interface ProveOptions { | ||
| 237 | 240 | readonly signal?: AbortSignal; |
| 238 | 241 | /** Walks the stranger's journey after the smoke checks; the forge passes `walkJourney`, and a test its own. Without it no walk runs. */ |
| 239 | 242 | 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>; | |
| 240 | 245 | /** |
| 241 | 246 | * This boot's prober key (`release/prober.ts`), handed to the journey so the forge's rate |
| 242 | 247 | * limits know it. |
| @@ -266,6 +271,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 266 | 271 | const baseFetch = o.fetch ?? fetch; |
| 267 | 272 | const doFetch = ((url, init) => baseFetch(url, o.signal ? { ...init, signal: init?.signal ? AbortSignal.any([init.signal, o.signal]) : o.signal } : init)) as typeof fetch; |
| 268 | 273 | const failedBefore = last?.version === o.version && !last.ok ? last : undefined; |
| 274 | let measured: Footprint | undefined; | |
| 269 | 275 | const finish = (failure?: string, slow = false, gaveUp = false, walked?: JourneyResult): Proof => { |
| 270 | 276 | if (o.signal?.aborted) throw new Error("the proof was aborted as hung"); |
| 271 | 277 | const at = new Date(now()).toISOString(); |
| @@ -273,6 +279,7 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 273 | 279 | version: o.version, |
| 274 | 280 | at, |
| 275 | 281 | ok: failure === undefined, |
| 282 | ...(measured === undefined ? {} : { footprint: measured }), | |
| 276 | 283 | ...(walked === undefined ? {} : { journey: { readyMs: walked.readyMs, firstActivityMs: walked.firstActivityMs, screens: walked.screens } }), |
| 277 | 284 | ...(failure === undefined |
| 278 | 285 | ? {} |
| @@ -304,6 +311,8 @@ export async function proveRelease(o: ProveOptions): Promise<Proof | undefined> | ||
| 304 | 311 | } |
| 305 | 312 | await sleep(POLL_MS); |
| 306 | 313 | } |
| 314 | // At rest: the version answers and nothing has loaded it yet. | |
| 315 | measured = await o.footprint?.().catch(() => undefined); | |
| 307 | 316 | const failures = await smoke(o.url, o.token ?? "", { |
| 308 | 317 | fetch: doFetch, |
| 309 | 318 | 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( | ||
| 134 | 134 | } |
| 135 | 135 | |
| 136 | 136 | 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 @@ | ||
| 26 | 26 | * Under them, the database's size (#12010, `dbSizeLines`): the file, its |
| 27 | 27 | * WAL and growth, then its largest tables and indexes, through the same |
| 28 | 28 | * `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. | |
| 29 | 31 | */ |
| 30 | 32 | |
| 31 | 33 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -45,6 +47,8 @@ import { dialog } from "../ui/widgets.ts"; | ||
| 45 | 47 | import { Scroll, ago, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts"; |
| 46 | 48 | import { largestDirs, type LargestDirs } from "../../system/largest_dirs.ts"; |
| 47 | 49 | 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"; | |
| 48 | 52 | import type { PaletteEntry } from "./entries.ts"; |
| 49 | 53 | import { readHealth } from "../../actions/health.ts"; |
| 50 | 54 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| @@ -180,6 +184,20 @@ export function largestLines(session: Session, found: LargestDirs | null, inner | ||
| 180 | 184 | ]; |
| 181 | 185 | } |
| 182 | 186 | |
| 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 | ||
| 183 | 201 | /** |
| 184 | 202 | * The database's size, as /system's "Database size" says it (#12010): the |
| 185 | 203 | * 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 | ||
| 346 | 364 | // The cache /system reads (#12010); a stale walk starts behind it, |
| 347 | 365 | // and only for a reader. |
| 348 | 366 | const db = reads ? dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log), true) : []; |
| 367 | const footprint = footprintLines(session); | |
| 349 | 368 | const buttons = dialog(s, area, ctx.theme.c, { |
| 350 | 369 | title: "System", |
| 351 | 370 | body: [ |
| @@ -355,6 +374,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio | ||
| 355 | 374 | ...(largest.length === 0 ? [] : ["", ...largest]), |
| 356 | 375 | // Under the largest directories with no blank between: the dialog does not scroll. |
| 357 | 376 | ...db, |
| 377 | ...(footprint.length === 0 ? [] : ["", ...footprint]), | |
| 358 | 378 | ...(offered ? ["", ...readerLines(session)] : []), |
| 359 | 379 | ...(features.length === 0 ? [] : ["", ...features]), |
| 360 | 380 | ...(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"; | ||
| 49 | 49 | import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts"; |
| 50 | 50 | import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts"; |
| 51 | 51 | 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"; | |
| 52 | 54 | import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords, type Footing, type Machine } from "../../system/footing.ts"; |
| 53 | 55 | import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts"; |
| 54 | 56 | import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts"; |
| @@ -94,6 +96,9 @@ export function largestDirsSection(found: LargestDirs | null): Html { | ||
| 94 | 96 | <p class="muted small">Measured ${time(found.at)}</p>`}</section>`; |
| 95 | 97 | } |
| 96 | 98 | |
| 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 | ||
| 97 | 102 | /** |
| 98 | 103 | * The database's size (#12010). On first view, the heading and one sentence: |
| 99 | 104 | * 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 { | ||
| 111 | 116 | ? "growth needs a day of readings" |
| 112 | 117 | : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`; |
| 113 | 118 | 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>`; | |
| 115 | 119 | const state = html`forge.db is ${bytes(size.fileBytes)}; ${grows} |
| 116 | 120 | ${said === undefined ? null : badge(said.cause === "db-size" ? `past ${bytes(DB_FILE_CAP)}` : `over ${bytes(DB_GROWTH_MAX)} a day`, { tone: "attn" })}`; |
| 117 | 121 | return html`<section class="settings-block" id="sys-db"><h2>Database size</h2> |
| @@ -311,6 +315,21 @@ export function footingSection(f: Footing): Html { | ||
| 311 | 315 | ${(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>`; |
| 312 | 316 | } |
| 313 | 317 | |
| 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 | ||
| 314 | 333 | /** |
| 315 | 334 | * The off-site backup lane (#11805): where the forge's own mirror pass |
| 316 | 335 | * 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> { | ||
| 564 | 583 | <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> |
| 565 | 584 | <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div> |
| 566 | 585 | <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section> |
| 586 | ${footprintSection(lastFootprint() ?? lastProof(ctx.app.config.dataDir)?.footprint ?? null)} | |
| 567 | 587 | ${footingSection(standsOn)} |
| 568 | 588 | ${offsiteSection(timePhase(ctx.request, "offsite", () => readOffsiteLane(ctx.app.config.dataDir, ctx.app.db)))} |
| 569 | 589 | ${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 () => { | ||
| 54 | 54 | "Largest under the forge's data", |
| 55 | 55 | "Database size", |
| 56 | 56 | "Processes", |
| 57 | "What the forge costs", | |
| 57 | 58 | "What the forge stands on", |
| 58 | 59 | "Off-site backup", |
| 59 | 60 | "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"; | ||
| 16 | 16 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; |
| 17 | 17 | import { hasGrant } from "../src/core/telemetry.ts"; |
| 18 | 18 | 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"; | |
| 20 | 20 | import type { DbSize } from "../src/system/db_size.ts"; |
| 21 | 21 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 22 | 22 | import { EGRESS_REPORTED } from "../src/system/egress_line.ts"; |
| 23 | import { keepFootprint } from "../src/system/footprint.ts"; | |
| 23 | 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 24 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | 26 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -429,3 +430,30 @@ describe("tui system telemetry readers (#9069)", () => { | ||
| 429 | 430 | resetTelemetryReads(); |
| 430 | 431 | }); |
| 431 | 432 | }); |
| 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 | }); |
new above
offline
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.