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

/system and the system MCP tool show the database's ten largest tables and indexes, its file size and its growth #12025

Merged opened this

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

Merged

torque/forge:torque/12010-system-and-the-system-mcp-tool-show-the → main 5 commits fork 1 thread · 1 unresolved

  • Merged. torque/12010-system-and-the-system-mcp-tool-show-the was merged into main as 7650df1.

Review

Sign in to review this change.
18 files changed +1032 −14
AGENTS.md +1
@@ -27,6 +27,7 @@ State shared between modules through process memory, not the database, is named
2727
2828 `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.
2929 `src/system/largest_dirs.ts`: `largestDirs`, the last measurement of the five largest directories under the forge's data (#11800), held 60 s so /system, its terminal screen (`src/tui/screens/system.ts`, `largestLines`) and the MCP `system` tool share one asynchronous walk; a walk that fails is not retried for 60 s; one reading, gone on restart.
30 `src/system/db_size.ts`: `dbSize`, the last walk of the database file's pages (#12010: the ten largest tables and indexes), held an hour so /system and the MCP `system` tool (`kind: size`) share one asynchronous walk; a walk that fails is not retried for an hour; one reading, gone on restart. The file's bytes and its growth are read fresh each time; growth comes from `db_size_day`, one row a UTC day, 60 kept.
3031
3132 `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.
3233
docs/MCP.md +1 −1
@@ -214,7 +214,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
214214 | `subscription_wake` | — | Admin: wake a resting subscription | 168 |
215215 | `subscriptions` | — | Claude accounts: scopes, budgets | 158 |
216216 | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 |
217 | `system` | `csp`, `routes`, `footing`, `offsite` | The forge machine now: CPU, memory, disk, network | 306 |
217 | `system` | `csp`, `routes`, `footing`, `offsite`, `size` | The forge machine now: CPU, memory, disk, network | 313 |
218218 | `take_back` | — | Open the pull that undoes a shipped pull | 219 |
219219 | `take_updates` | — | Take the parent forge's updates into a fork | 176 |
220220 | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 |
docs/design/attention-budget.json +3 −3
@@ -173,15 +173,15 @@
173173 "system": {
174174 "390": {
175175 "words": 212,
176 "blocks": 67,
176 "blocks": 69,
177177 "colours": 0
178178 },
179179 "1280": {
180180 "words": 212,
181 "blocks": 67,
181 "blocks": 69,
182182 "colours": 0
183183 },
184 "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."
184 "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."
185185 },
186186 "explore": {
187187 "390": {
docs/features/db-size.md added +63
@@ -0,0 +1,63 @@
1 # Database size (#12010)
2
3 The forge knows how big its own database is, and which tables and indexes
4 make it so. Bloat shows up on /system, not in a profiler.
5
6 ## Where to see it
7
8 - **/system → Database size** (the owner). One sentence stays on first
9 view: the file's size and its growth a day, with a badge when either is
10 worth a look. Behind the disclosure:
11 - Fact rows (`dl`): `forge.db`'s bytes, the WAL's bytes, the free pages
12 inside the file (what `VACUUM` would give back), and how much the file
13 grows (or shrinks) a day.
14 - One table: the ten largest tables with rows and bytes, then the ten
15 largest indexes with the table each one serves and bytes.
16 - **The terminal → System** (a forge admin, or an agent granted
17 `telemetry:read`). One line under the largest directories: the file's size
18 and its growth a day. `d` opens **Database size**, a scrolled screen with
19 the WAL, the free pages, the ten largest tables and the ten largest
20 indexes. It reads the same cache as the page.
21 - **MCP `system` with `{ "kind": "size" }`** (a forge admin, or an agent
22 granted `telemetry:read`). It returns the same data as JSON:
23 `{ fileBytes, walBytes, growth: { perDay, days, readings }, walk: { at, pageSize, freeBytes, tables, indexes } | null }`.
24
25 ## How it is measured
26
27 Bun's SQLite is built without `dbstat` and without `sqlite_dbpage`, so
28 `src/system/db_size.ts` reads the file itself. It reads in 4 MiB
29 asynchronous chunks, so the event loop is never held. For every b-tree page
30 it records the page's kind, its cells and its children. Then it walks each
31 b-tree down from its root page in `sqlite_schema`:
32
33 - A table's bytes are its interior, leaf and overflow pages times the page
34 size.
35 - A rowid table's rows are its leaf cells.
36 - A freed page belongs to no tree.
37
38 On a seeded database these figures match SQLite's own `dbstat` page for page.
39
40 The file is read as of its last checkpoint, so pages still only in the WAL
41 are not counted. The WAL's bytes are shown beside it. One walk is shared by
42 /system, the terminal and the MCP tool. The forge walks at most once an
43 hour, and only when someone opens /system or the terminal's System screen,
44 or calls the tool. A walk reads the whole file once: on a 4.5 GB database
45 that is 4.5 GB of reads, at most once an hour, and none in an hour nobody
46 asks. A walk that fails is logged (`database size walk failed`) and is
47 tried again an hour later.
48
49 ## Growth, and when the owner hears
50
51 On its two-minute tick, the forge keeps the file's first size reading of
52 each UTC day in `db_size_day`, and keeps the last 60 days.
53
54 Growth a day is the file's size now minus the oldest reading from the last
55 seven days, divided by the time between them. Growth is not shown until the
56 readings span 20 hours.
57
58 The vitals add a need, linked to /system, when either of these is true:
59
60 - `db-size`: the file is past 2 GiB (`0x7fff0000` bytes, the most Bun's
61 SQLite maps, its `MAX_MMAP_SIZE`).
62 - `db-growth`: the file grows faster than 64 MiB a day. At that rate an
63 empty database reaches the map's cap in 32 days.
scripts/header-width-allow.json +1 −2
@@ -31,6 +31,5 @@
3131 "test/shelf-forty.test.ts",
3232 "test/spawn-cell.test.ts",
3333 "test/thread-render-timing.test.ts",
34 "test/traffic-truth.test.ts",
35 "test/vitals.test.ts"
34 "test/traffic-truth.test.ts"
3635 ]
src/core/migration-pins/20261114000200-db-size-day.pin added +1
@@ -0,0 +1,1 @@
1 05f52b5ff4d3626dd408ae39a6cd36d494242b1d7f3dec269ddc521bfd7c4259
src/core/migrations/20261114000200-db-size-day.ts added +10
@@ -0,0 +1,10 @@
1 // The database file's size, one reading a UTC day (#12010), so /system and the vitals can say how
2 // fast it grows. `src/system/db_size.ts` writes the first reading of each day and keeps 60 days;
3 // every read takes all of them (at most 60 rows), so the primary key is the only index.
4 export default `
5 CREATE TABLE db_size_day (
6 day TEXT PRIMARY KEY,
7 at TEXT NOT NULL,
8 bytes INTEGER NOT NULL
9 ) WITHOUT ROWID;
10 `;
src/http/mcp/tools/system.ts +10 −3
@@ -10,14 +10,15 @@ import { slowestRoutes } from "../../../perf/watch.ts";
1010 import { footing, riskLines } from "../../../system/footing.ts";
1111 import { laneWords, readOffsiteLane } from "../../../snapshot/offsite_lane.ts";
1212 import { largestDirs } from "../../../system/largest_dirs.ts";
13 import { dbSize } from "../../../system/db_size.ts";
1314 import { good, bad, type Tool } from "../shared.ts";
1415
1516 export const tool: Tool = {
1617 name: "system",
17 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `largestDirs`: the five largest directories under the forge's data as {at, dirs: [{name, bytes}]} (null while first measured; at most once a minute), `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. `{ kind: \"offsite\" }`: the off-site backup lane (#11805), as /system's \"Off-site backup\" and the terminal's: `{ health, says, where, sealed, lastSuccess, newest, error, drill: { at, verdict, from, offSite, reason } | null }`; a forge admin only",
18 doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `largestDirs`: the five largest directories under the forge's data as {at, dirs: [{name, bytes}]} (null while first measured; at most once a minute), `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. `{ kind: \"offsite\" }`: the off-site backup lane (#11805), as /system's \"Off-site backup\" and the terminal's: `{ health, says, where, sealed, lastSuccess, newest, error, drill: { at, verdict, from, offSite, reason } | null }`; a forge admin only. `{ kind: \"size\" }`: the database's size (#12010), as /system's \"Database size\": `{ fileBytes, walBytes, growth: { perDay, days, readings: [{day, bytes}] }, walk: { at, pageSize, freeBytes, tables: [{name, bytes, rows}], indexes: [{name, table, bytes}] } | null }`, ten of each, largest first, the walk at most an hour old (null while the first runs); a forge admin, or an agent granted `telemetry:read`",
1819 description:
1920 "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).",
20 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing", "offsite"] } } },
21 inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing", "offsite", "size"] } } },
2122 writes: false,
2223 async run(ctx, user, args) {
2324 if (args["kind"] !== undefined) {
@@ -27,6 +28,12 @@ export const tool: Tool = {
2728 noteRead(ctx.app, user, reader, "system");
2829 return good({ routes: slowestRoutes(perfRecord, Date.now()) });
2930 }
31 if (args["kind"] === "size") {
32 const reader = telemetryReader(ctx.app, user);
33 if (!reader) return bad("forbidden", "only an admin of this forge, or an agent granted telemetry:read, reads its database's size");
34 noteRead(ctx.app, user, reader, "system");
35 return good(await dbSize.read(ctx.app.db, ctx.app.config.dataDir, undefined, ctx.app.log));
36 }
3037 if (args["kind"] === "footing") {
3138 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads what it stands on");
3239 const f = await footing.read();
@@ -37,7 +44,7 @@ export const tool: Tool = {
3744 const lane = readOffsiteLane(ctx.app.config.dataDir, ctx.app.db);
3845 return good({ ...lane, says: laneWords(lane) });
3946 }
40 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, offsite, or absent for the machine");
47 if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, offsite, size, or absent for the machine");
4148 if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations");
4249 const now = new Date();
4350 return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) });
src/kernel/vitals.ts +8 −1
@@ -28,6 +28,7 @@ import { STALE_AFTER_MS } from "../runs/lease.ts";
2828 import { ownRepoPath } from "./own_repo.ts";
2929 import { BUDGETS } from "./budgets.ts";
3030 import { openDbFiles } from "./file_modes.ts";
31 import { dbFile, dbGrowth, dbSizeNeed, fileBytes } from "../system/db_size.ts";
3132 import { backupVerdict, isWatched } from "../snapshot/bucket.ts";
3233 import { lastDrill } from "../snapshot/drills.ts";
3334 import { lastMirror, mirrorNeed } from "../snapshot/mirror.ts";
@@ -73,7 +74,8 @@ export interface EnvCount {
7374
7475 /**
7576 * One cause of the forge being off: the `needs` sentence, a `cause` key that
76 * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`, `errors`, `internal`,
77 * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`,
78 * `db-size`, `db-growth`, `errors`, `internal`,
7779 * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence
7880 * (the oldest evidence the forge holds, else the first time this process saw
7981 * it, held until the cause clears; `at` is never the answer to a repeat
@@ -368,6 +370,11 @@ export function vitals(input: VitalsInput): Vitals {
368370 for (const file of openDbFiles(input.dataDir))
369371 need(`file:${file.name}`, file.sentence, undefined, "/settings/capacity");
370372
373 // A database past the 2 GiB Bun maps, or growing faster than 64 MiB a day, is said (#12010).
374 const dbBytes = fileBytes(dbFile(input.dataDir));
375 const dbBig = dbSizeNeed(dbBytes, dbGrowth(input.db, dbBytes, now));
376 if (dbBig !== undefined) need(dbBig.cause, dbBig.sentence, undefined, "/system");
377
371378 // Snapshots on the volume they copy, and no mirror, share its one point of failure.
372379 const snaps = input.snapshots;
373380 if (
src/main.ts +8
@@ -23,6 +23,7 @@ import { readOnlyFrom } from "./http/read_only.ts";
2323 import { warmDisplays } from "./display/known.ts";
2424 import { warmSearch } from "./search/warm.ts";
2525 import { warmFlow } from "./system/flow_cache.ts";
26 import { noteDbSize } from "./system/db_size.ts";
2627 import { backfillCarried } from "./core/carried.ts";
2728 import { refreshCatalogQuietly } from "./packs/catalog.ts";
2829 import { loadTrustedHosts } from "./origins/trusted-hosts.ts";
@@ -687,6 +688,13 @@ const tickAsks = async (): Promise<void> => {
687688 } catch (error) {
688689 app.log.error("asks ledger failed", { error: String(error) });
689690 }
691 // The database's size, its first reading each UTC day (#12010): growth for /system and the
692 // vitals.
693 try {
694 noteDbSize(app.db, app.config.dataDir);
695 } catch (error) {
696 app.log.warn("database size reading failed", { error: String(error) });
697 }
690698 // The degraded history rides the same tick; it catches its own errors.
691699 try {
692700 recordDegraded(
src/system/db_size.ts added +417
@@ -0,0 +1,417 @@
1 /**
2 * How big the forge's database is, and what makes it so (#12010): the
3 * file's bytes, the ten largest tables (with their rows) and the ten largest
4 * indexes, and how fast the file grows a day, from one reading a UTC day
5 * kept for 60 days (`db_size_day`).
6 *
7 * Bun's SQLite (3.53, embedded) is built without `dbstat` and without
8 * `sqlite_dbpage` (`PRAGMA compile_options`, checked on Bun 1.3), and where
9 * `dbstat` exists it walks every page on the thread that asks. So the sizes
10 * come from the file itself, read in large asynchronous chunks so the event
11 * loop is never held: each b-tree page's kind, cells and children
12 * (https://www.sqlite.org/fileformat2.html §1.6), then each b-tree walked
13 * down from its root page in `sqlite_schema`, so a freed page never counts.
14 * A b-tree's bytes are its pages (interior, leaf and overflow) times the page
15 * size; a rowid table's rows are its leaf cells, a WITHOUT ROWID table's its
16 * cells. The file is as of the last checkpoint: pages still only in the WAL
17 * are not in it, and the WAL's own bytes are named beside it.
18 *
19 * One measurement is shared by /system and the MCP `system` tool, taken at
20 * most once an hour, and no request waits on it longer than 0.8 s.
21 */
22
23 import type { Database } from "bun:sqlite";
24 import { statSync } from "node:fs";
25 import { join } from "node:path";
26 import type { Log } from "../kernel/log.ts";
27
28 /** Where a failed walk is said: the forge's log (`app.log`). */
29 type Warn = Pick<Log, "warn">;
30
31 /** A table and what it holds: bytes of its b-tree, and its rows. */
32 export interface TableSize {
33 readonly name: string;
34 readonly bytes: number;
35 readonly rows: number;
36 }
37
38 /** An index, the table it serves, and the bytes of its b-tree. */
39 export interface IndexSize {
40 readonly name: string;
41 readonly table: string;
42 readonly bytes: number;
43 }
44
45 /** What one walk of the file found. */
46 export interface DbWalk {
47 /** When the walk finished, ISO. */
48 readonly at: string;
49 readonly pageSize: number;
50 /** Bytes on the freelist: room `VACUUM` would give back. */
51 readonly freeBytes: number;
52 /** The `TOP` largest tables, largest first. */
53 readonly tables: readonly TableSize[];
54 /** The `TOP` largest indexes, largest first. */
55 readonly indexes: readonly IndexSize[];
56 }
57
58 /** Growth per day, measured from the daily readings. */
59 export interface DbGrowth {
60 /** Bytes a day, averaged over `days`; null until the readings span a day. */
61 readonly perDay: number | null;
62 /** The span the average is over, in days (one decimal). */
63 readonly days: number;
64 /** Every reading kept, oldest first: one a UTC day, at most `KEEP_DAYS`. */
65 readonly readings: readonly { readonly day: string; readonly bytes: number }[];
66 }
67
68 /** The database's size now, as /system and the MCP tool give it. */
69 export interface DbSize {
70 /** Bytes of `forge.db` now. */
71 readonly fileBytes: number;
72 /** Bytes of `forge.db-wal` now (0 when absent). */
73 readonly walBytes: number;
74 readonly growth: DbGrowth;
75 /** The last walk of the file, or null while the first runs. */
76 readonly walk: DbWalk | null;
77 }
78
79 /** How many tables and indexes are named. */
80 export const TOP = 10;
81 /** Days of readings kept. */
82 export const KEEP_DAYS = 60;
83 /** The window growth is averaged over, at most. */
84 export const GROWTH_WINDOW_DAYS = 7;
85 /**
86 * Bun's SQLite maps at most 0x7fff0000 bytes (`MAX_MMAP_SIZE` in
87 * `PRAGMA compile_options`): 2 GiB less 64 KiB. Past it, reads leave the map
88 * for read() calls (`src/kernel/db.ts`), so a file this size is said.
89 */
90 export const DB_FILE_CAP = 0x7fff_0000;
91 /**
92 * Growth worth saying: 64 MiB a day takes an empty database to the map's cap
93 * in 32 days, faster than any month of the forge's own use has needed.
94 */
95 export const DB_GROWTH_MAX = 64 * 1024 * 1024;
96
97 const DAY_MS = 86_400_000;
98 /** Growth is not averaged over less than this, so one busy hour is not a day. */
99 const MIN_SPAN_MS = 20 * 3_600_000;
100
101 /** The database file under `dataDir`. */
102 export const dbFile = (dataDir: string): string => join(dataDir, "forge.db");
103
104 /** Bytes of a file, or 0 when it is absent. */
105 export function fileBytes(path: string): number {
106 try {
107 return statSync(path).size;
108 } catch {
109 return 0;
110 }
111 }
112
113 // ---------------------------------------------------------------- the walk
114
115 /** A SQLite varint at `at`: its value and the bytes it took (fileformat2 §1.6, "varint"). */
116 function varint(b: Uint8Array, at: number, end: number): [number, number] {
117 let v = 0;
118 for (let i = 0; i < 8; i++) {
119 if (at + i >= end) return [v, i];
120 const byte = b[at + i]!;
121 v = v * 128 + (byte & 0x7f);
122 if ((byte & 0x80) === 0) return [v, i + 1];
123 }
124 if (at + 8 >= end) return [v, 8];
125 return [v * 256 + b[at + 8]!, 9];
126 }
127
128 /**
129 * Overflow pages a cell's payload of `p` bytes spills into, for usable page
130 * size `u` (fileformat2 §1.6, "Cell payload overflow pages"): X is the most
131 * kept on the page (u-35 on a table leaf, ((u-12)·64/255)-23 on an index),
132 * M = ((u-12)·32/255)-23 the least, K = M + ((P-M) mod (u-4)); the page keeps
133 * K when K ≤ X, else M, and the rest fills pages of u-4 bytes each.
134 */
135 export function overflowPages(p: number, u: number, tableLeaf: boolean): number {
136 const x = tableLeaf ? u - 35 : Math.floor(((u - 12) * 64) / 255) - 23;
137 if (p <= x) return 0;
138 const m = Math.floor(((u - 12) * 32) / 255) - 23;
139 const k = m + ((p - m) % (u - 4));
140 const local = k <= x ? k : m;
141 return Math.ceil((p - local) / (u - 4));
142 }
143
144 /** What the walk keeps of every page: b-tree kind (0 for none), cells, overflow pages, children. */
145 interface Pages {
146 readonly kind: Uint8Array;
147 readonly cells: Uint32Array;
148 readonly overflow: Uint32Array;
149 readonly children: Map<number, number[]>;
150 }
151
152 /**
153 * Reads page `n` (at `base` in `buf`) into `pages` when it is a well-formed
154 * b-tree page; anything else (overflow, freelist, a torn page) stays kind 0.
155 */
156 function readPage(buf: Uint8Array, base: number, n: number, pageSize: number, usable: number, pages: Pages, count: number): void {
157 const h = base + (n === 1 ? 100 : 0);
158 const kind = buf[h]!;
159 if (kind !== 2 && kind !== 5 && kind !== 10 && kind !== 13) return;
160 const interior = kind === 2 || kind === 5;
161 const cellCount = (buf[h + 3]! << 8) | buf[h + 4]!;
162 const ptrs = h + (interior ? 12 : 8);
163 const end = base + usable;
164 if (ptrs + 2 * cellCount > end) return;
165 const kids: number[] = [];
166 let spill = 0;
167 for (let i = 0; i < cellCount; i++) {
168 const off = (buf[ptrs + 2 * i]! << 8) | buf[ptrs + 2 * i + 1]!;
169 let at = base + off;
170 if (off < ptrs + 2 * cellCount - base || at >= end) return;
171 if (interior) {
172 if (at + 4 > end) return;
173 const child = ((buf[at]! << 24) >>> 0) + (buf[at + 1]! << 16) + (buf[at + 2]! << 8) + buf[at + 3]!;
174 if (child < 1 || child > count) return;
175 kids.push(child);
176 at += 4;
177 }
178 if (kind === 5) continue; // a table's interior cell is a key, no payload
179 const [payload] = varint(buf, at, end);
180 spill += overflowPages(payload, usable, kind === 13);
181 }
182 if (interior) {
183 const right = ((buf[h + 8]! << 24) >>> 0) + (buf[h + 9]! << 16) + (buf[h + 10]! << 8) + buf[h + 11]!;
184 if (right < 1 || right > count) return;
185 kids.push(right);
186 pages.children.set(n, kids);
187 }
188 pages.kind[n] = kind;
189 pages.cells[n] = cellCount;
190 pages.overflow[n] = spill;
191 }
192
193 /** One b-tree to size: its name, its table, whether it is an index, and its root page. */
194 export interface Root {
195 readonly name: string;
196 readonly table: string;
197 readonly index: boolean;
198 readonly root: number;
199 }
200
201 /** What one b-tree holds: pages (with overflow) and the cells that are rows or entries. */
202 export interface TreeSize extends Root {
203 readonly pages: number;
204 readonly rows: number;
205 }
206
207 /** The pages read per chunk: 1,024 pages of 4 KiB is 4 MiB a read. */
208 const CHUNK_PAGES = 1024;
209
210 /**
211 * Sizes each b-tree in the file at `path` by walking it from its root. The
212 * file is read in chunks of `CHUNK_PAGES`, each read awaited, so the loop is
213 * free between them; the parse of a chunk is a few hundred microseconds.
214 */
215 export async function walkFile(path: string, roots: readonly Root[]): Promise<{ pageSize: number; freePages: number; trees: TreeSize[] }> {
216 const file = Bun.file(path);
217 const head = new Uint8Array(await file.slice(0, 100).arrayBuffer());
218 if (head.length < 100 || new TextDecoder().decode(head.subarray(0, 15)) !== "SQLite format 3")
219 throw new Error(`${path} is not a SQLite database`);
220 const raw = (head[16]! << 8) | head[17]!;
221 const pageSize = raw === 1 ? 65536 : raw;
222 if (pageSize < 512 || (pageSize & (pageSize - 1)) !== 0) throw new Error(`${path} has page size ${raw}`);
223 const usable = pageSize - head[20]!;
224 const freePages = ((head[36]! << 24) >>> 0) + (head[37]! << 16) + (head[38]! << 8) + head[39]!;
225 const count = Math.floor(file.size / pageSize);
226 const pages: Pages = {
227 kind: new Uint8Array(count + 1),
228 cells: new Uint32Array(count + 1),
229 overflow: new Uint32Array(count + 1),
230 children: new Map(),
231 };
232 for (let first = 1; first <= count; first += CHUNK_PAGES) {
233 const last = Math.min(count, first + CHUNK_PAGES - 1);
234 const buf = new Uint8Array(await file.slice((first - 1) * pageSize, last * pageSize).arrayBuffer());
235 for (let n = first; n <= last && (n - first + 1) * pageSize <= buf.length; n++)
236 readPage(buf, (n - first) * pageSize, n, pageSize, usable, pages, count);
237 }
238 // Top down from each root: a page is counted once, by the first tree that reaches it.
239 const seen = new Uint8Array(count + 1);
240 const trees: TreeSize[] = [];
241 for (const r of roots) {
242 let total = 0;
243 let rows = 0;
244 const stack = r.root >= 1 && r.root <= count ? [r.root] : [];
245 while (stack.length > 0) {
246 const n = stack.pop()!;
247 if (seen[n] === 1 || pages.kind[n] === 0) continue;
248 seen[n] = 1;
249 const kind = pages.kind[n]!;
250 total += 1 + pages.overflow[n]!;
251 // Rows: a rowid table's leaf cells; an index (or WITHOUT ROWID table) holds an entry in
252 // every cell.
253 if (kind === 13 || kind === 10 || kind === 2) rows += pages.cells[n]!;
254 for (const c of pages.children.get(n) ?? []) stack.push(c);
255 }
256 trees.push({ ...r, pages: total, rows });
257 }
258 return { pageSize, freePages, trees };
259 }
260
261 /** Every b-tree the schema names, with `sqlite_schema` itself on page 1. */
262 export function rootsOf(db: Database): Root[] {
263 const rows = db
264 .query<{ type: string; name: string; tbl_name: string; rootpage: number }, []>(
265 "SELECT type, name, tbl_name, rootpage FROM sqlite_schema WHERE type IN ('table', 'index') AND rootpage > 0",
266 )
267 .all();
268 return [
269 { name: "sqlite_schema", table: "sqlite_schema", index: false, root: 1 },
270 ...rows.map((r) => ({ name: r.name, table: r.tbl_name, index: r.type === "index", root: r.rootpage })),
271 ];
272 }
273
274 /** The `n` largest, largest first; equal sizes by name, so the order is stable. */
275 export function largestFirst<T extends { name: string; bytes: number }>(rows: readonly T[], n = TOP): T[] {
276 return [...rows].sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)).slice(0, n);
277 }
278
279 /** Walks the database file under `dataDir` and keeps the ten largest tables and indexes. */
280 export async function measureDbWalk(db: Database, dataDir: string, clock: () => number = Date.now): Promise<DbWalk> {
281 const { pageSize, freePages, trees } = await walkFile(dbFile(dataDir), rootsOf(db));
282 const tables = trees
283 .filter((t) => !t.index)
284 .map((t) => ({ name: t.name, bytes: t.pages * pageSize, rows: t.rows }));
285 const indexes = trees
286 .filter((t) => t.index)
287 .map((t) => ({ name: t.name, table: t.table, bytes: t.pages * pageSize }));
288 return {
289 at: new Date(clock()).toISOString(),
290 pageSize,
291 freeBytes: freePages * pageSize,
292 tables: largestFirst(tables),
293 indexes: largestFirst(indexes),
294 };
295 }
296
297 // ---------------------------------------------------------------- growth
298
299 /** The UTC day of an instant. */
300 const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
301
302 /**
303 * Keeps the first reading of each UTC day and drops those older than
304 * `KEEP_DAYS`. Called on the forge's two-minute tick; a day already read
305 * costs one ignored insert.
306 */
307 export function noteDbSize(db: Database, dataDir: string, now: number = Date.now()): void {
308 const bytes = fileBytes(dbFile(dataDir));
309 if (bytes === 0) return;
310 db.query("INSERT OR IGNORE INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)").run(dayOf(now), new Date(now).toISOString(), bytes);
311 db.query("DELETE FROM db_size_day WHERE day < ?").run(dayOf(now - KEEP_DAYS * DAY_MS));
312 }
313
314 /**
315 * Growth a day: the file now against the oldest reading inside the last
316 * `GROWTH_WINDOW_DAYS`, over the time between them; null while that is
317 * under 20 hours.
318 */
319 export function dbGrowth(db: Database, bytesNow: number, now: number = Date.now()): DbGrowth {
320 const rows = db
321 .query<{ day: string; at: string; bytes: number }, []>("SELECT day, at, bytes FROM db_size_day ORDER BY day")
322 .all();
323 const readings = rows.map((r) => ({ day: r.day, bytes: r.bytes }));
324 const from = rows.find((r) => Date.parse(r.at) >= now - GROWTH_WINDOW_DAYS * DAY_MS);
325 const span = from === undefined ? 0 : now - Date.parse(from.at);
326 if (from === undefined || span < MIN_SPAN_MS) return { perDay: null, days: 0, readings };
327 return {
328 perDay: Math.round((bytesNow - from.bytes) / (span / DAY_MS)),
329 days: Math.round((span / DAY_MS) * 10) / 10,
330 readings,
331 };
332 }
333
334 /** One sentence for the vitals when the file is past the map's cap or grows too fast. */
335 export function dbSizeNeed(
336 fileBytes: number,
337 growth: Pick<DbGrowth, "perDay" | "days">,
338 ): { cause: "db-size" | "db-growth"; sentence: string } | undefined {
339 const gib = (n: number): string => (n / 1024 ** 3).toFixed(1);
340 const mib = (n: number): string => String(Math.round(n / 1024 ** 2));
341 if (fileBytes > DB_FILE_CAP)
342 return {
343 cause: "db-size",
344 sentence: `The database is ${gib(fileBytes)} GiB, past the 2 GiB Bun maps; /system names its largest tables.`,
345 };
346 if (growth.perDay !== null && growth.perDay > DB_GROWTH_MAX)
347 return {
348 cause: "db-growth",
349 sentence: `The database grows ${mib(growth.perDay)} MiB a day over ${growth.days} days, past the ${mib(DB_GROWTH_MAX)} MiB a day worth a look; /system names its largest tables.`,
350 };
351 return undefined;
352 }
353
354 // ---------------------------------------------------------------- the shared reading
355
356 const FRESH_MS = 3_600_000;
357 const WAIT_MS = 800;
358
359 /** The last walk, shared by /system and the MCP `system` tool; one per process, gone on restart. */
360 export function dbSizeCache(
361 walk: (db: Database, dataDir: string) => Promise<DbWalk> = (db, d) => measureDbWalk(db, d),
362 clock: () => number = Date.now,
363 ) {
364 let last: { at: number; dataDir: string; value: DbWalk } | null = null;
365 // A walk that failed waits an hour too, so a broken file costs one try an hour.
366 let triedAt: { at: number; dataDir: string } | null = null;
367 let pending: Promise<DbWalk | null> | null = null;
368 const due = (dataDir: string): boolean =>
369 pending === null &&
370 (last === null || last.dataDir !== dataDir || clock() - last.at > FRESH_MS) &&
371 (triedAt === null || triedAt.dataDir !== dataDir || clock() - triedAt.at > FRESH_MS);
372 const refresh = (db: Database, dataDir: string, log?: Warn): Promise<DbWalk | null> => {
373 triedAt = { at: clock(), dataDir };
374 pending = walk(db, dataDir)
375 .then((value) => {
376 last = { at: clock(), dataDir, value };
377 return value;
378 })
379 .catch((e: unknown) => {
380 // Said, not swallowed: a broken walk shows in the log, and the page keeps the last one.
381 log?.warn("database size walk failed", { dataDir, error: e instanceof Error ? e.message : String(e) });
382 return null;
383 })
384 .finally(() => {
385 pending = null;
386 });
387 return pending;
388 };
389 const current = (dataDir: string): DbWalk | null => (last?.dataDir === dataDir ? last.value : null);
390 const now = (db: Database, dataDir: string, w: DbWalk | null): DbSize => {
391 const file = fileBytes(dbFile(dataDir));
392 return { fileBytes: file, walBytes: fileBytes(`${dbFile(dataDir)}-wal`), growth: dbGrowth(db, file, clock()), walk: w };
393 };
394 return {
395 /** The size now with the last walk; a new walk starts behind it when that is an hour old. */
396 peek(db: Database, dataDir: string, log?: Warn): DbSize {
397 if (due(dataDir)) void refresh(db, dataDir, log);
398 return now(db, dataDir, current(dataDir));
399 },
400 /** The size now with a walk under an hour old if one ends within `waitMs`, else the last. */
401 async read(db: Database, dataDir: string, waitMs = WAIT_MS, log?: Warn): Promise<DbSize> {
402 const running = due(dataDir) ? refresh(db, dataDir, log) : pending;
403 if (running === null) return now(db, dataDir, current(dataDir));
404 let deadline: ReturnType<typeof setTimeout> | undefined;
405 const timer = new Promise<null>((r) => {
406 deadline = setTimeout(() => r(null), waitMs);
407 });
408 try {
409 return now(db, dataDir, (await Promise.race([running, timer])) ?? current(dataDir));
410 } finally {
411 clearTimeout(deadline);
412 }
413 },
414 };
415 }
416
417 export const dbSize = dbSizeCache();
src/tui/screens/system.ts +79 −1
@@ -23,6 +23,9 @@
2323 * Below the telemetry history, the five largest directories under the
2424 * forge's data (#11800, `largestLines`), read through the cache /system and
2525 * the MCP `system` tool share and gated as they are (`telemetryReader`).
26 * Under them, the database's size (#12010, `dbSizeLines`): the file, its
27 * WAL and growth, then its largest tables and indexes, through the same
28 * `dbSize` cache and gate as /system and the MCP tool, never waited on.
2629 */
2730
2831 import type { Ctx, Screen } from "../app/app.ts";
@@ -41,6 +44,7 @@ import { pad, truncate, wrap } from "../term/width.ts";
4144 import { dialog } from "../ui/widgets.ts";
4245 import { Scroll, ago, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts";
4346 import { largestDirs, type LargestDirs } from "../../system/largest_dirs.ts";
47 import { dbSize, dbSizeNeed, type DbSize } from "../../system/db_size.ts";
4448 import type { PaletteEntry } from "./entries.ts";
4549 import { readHealth } from "../../actions/health.ts";
4650 import { egressReportedLine } from "../../system/egress_line.ts";
@@ -176,6 +180,73 @@ export function largestLines(session: Session, found: LargestDirs | null, inner
176180 ];
177181 }
178182
183 /**
184 * The database's size, as /system's "Database size" says it (#12010): the
185 * file, its WAL, the room inside and its growth, then the largest tables
186 * (rows, bytes) and indexes (the table each serves, bytes). `brief` is the
187 * System dialog's few lines, which name `d` for the rest (`dbSizeScreen`).
188 * Empty for anyone the page and the MCP tool refuse.
189 */
190 export function dbSizeLines(session: Session, size: DbSize, brief = false, inner = WIDTH - 6): string[] {
191 if (!telemetryReader(session.app, session.user)) return [];
192 const g = size.growth;
193 const grows =
194 g.perDay === null ? "growth needs a day of readings" : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`;
195 const said = dbSizeNeed(size.fileBytes, g);
196 const w = size.walk;
197 const head = [
198 "Database size",
199 ...wrap(`forge.db ${bytes(size.fileBytes)}, WAL ${bytes(size.walBytes)}${w === null ? "" : `, ${bytes(w.freeBytes)} free inside`}; ${grows}`, inner),
200 ...(said === undefined ? [] : wrap(said.sentence, inner)),
201 ];
202 // One line in the dialog, which does not scroll: the rest is a key away.
203 if (brief) {
204 const day = g.perDay === null ? "growth needs a day" : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day`;
205 return [truncate(`database ${bytes(size.fileBytes)}, ${day} · d: its largest tables`, inner), ...(said === undefined ? [] : wrap(said.sentence, inner))];
206 }
207 const line = (name: string, value: string): string => truncate(`${pad(truncate(name, inner - 12), inner - 12)} ${pad(value, 11, "right")}`, inner);
208 if (w === null) return [...head, "", "its tables are being measured; open again in a moment"];
209 return [
210 ...head,
211 "",
212 "Tables",
213 ...w.tables.map((t) => line(`${t.name} (${t.rows.toLocaleString("en-US")} rows)`, bytes(t.bytes))),
214 "",
215 "Indexes",
216 ...w.indexes.map((i) => line(`${i.name} on ${i.table}`, bytes(i.bytes))),
217 "",
218 `measured ${ago(w.at)}, at most once an hour`,
219 ];
220 }
221
222 /**
223 * The whole of `dbSizeLines`, scrolled, through the shared cache; `d` on the
224 * System dialog opens it.
225 */
226 export function dbSizeScreen(session: Session, ctx: Ctx): Screen {
227 const scroll = new Scroll();
228 const lines = (): string[] => dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log));
229 return {
230 title: "Database size",
231 keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")],
232 render(area, s) {
233 const c = ctx.theme.c;
234 const now = lines();
235 const spans = now.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
236 drawLines(s, area, c, scroll, spans);
237 hitScroll(ctx, area, scroll, now.length);
238 },
239 key(e) {
240 if (scroll.key(e, lines().length)) return true;
241 if (e.key === "esc" || e.key === "q") {
242 ctx.pop();
243 return true;
244 }
245 return false;
246 },
247 };
248 }
249
179250 /** Who may grant telemetry:read: the MCP tool's gate (`mayBoards`), a forge admin who is a person. */
180251 const mayGrant = (session: Session | undefined): boolean => session?.user?.isAdmin === true && session.user.kind !== "agent";
181252
@@ -234,6 +305,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
234305 // read on open and when `h` moves the window, not on every paint
235306 let history = historyLines(session, WINDOWS[span]!);
236307 const offered = mayGrant(session);
308 const reads = telemetryReader(session.app, session.user) !== undefined;
237309 const askGrant = (): void => {
238310 if (!mayGrant(session)) return ctx.toast("telemetry:read is a forge admin's to grant", "danger");
239311 const agents = grantable(session.db);
@@ -266,11 +338,14 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
266338 };
267339 const screen: Screen = {
268340 title: "System",
269 keys: [binding(["h"], "window"), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")],
341 keys: [binding(["h"], "window"), ...(reads ? [binding(["d"], "database size")] : []), ...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")],
270342 render(area, s) {
271343 const features = featuresLines(session);
272344 // Never waits on the walk: the last measurement, a new one started behind it when stale.
273345 const largest = largestLines(session, largestDirs.peek(session.app.config.dataDir));
346 // The cache /system reads (#12010); a stale walk starts behind it,
347 // and only for a reader.
348 const db = reads ? dbSizeLines(session, dbSize.peek(session.app.db, session.app.config.dataDir, session.app.log), true) : [];
274349 const buttons = dialog(s, area, ctx.theme.c, {
275350 title: "System",
276351 body: [
@@ -278,6 +353,8 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
278353 "",
279354 ...history,
280355 ...(largest.length === 0 ? [] : ["", ...largest]),
356 // Under the largest directories with no blank between: the dialog does not scroll.
357 ...db,
281358 ...(offered ? ["", ...readerLines(session)] : []),
282359 ...(features.length === 0 ? [] : ["", ...features]),
283360 ...(offered ? ["", grantLine(session), "g: grant or take back telemetry:read"] : []),
@@ -293,6 +370,7 @@ export function systemScreen(ctx: Ctx, lines: readonly string[], session: Sessio
293370 span = (span + 1) % WINDOWS.length;
294371 history = historyLines(session, WINDOWS[span]!);
295372 } else if (e.key === "g") askGrant();
373 else if (e.key === "d" && reads) later(ctx, () => dbSizeScreen(session, ctx));
296374 else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
297375 return true;
298376 },
src/web/pages/system.ts +42
@@ -51,6 +51,7 @@ import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords
5151 import { laneWords, readOffsiteLane, type OffsiteLane } from "../../snapshot/offsite_lane.ts";
5252 import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts";
5353 import { LARGEST_DIRS, largestDirs, type LargestDirs } from "../../system/largest_dirs.ts";
54 import { DB_FILE_CAP, DB_GROWTH_MAX, TOP, dbSize, dbSizeNeed, type DbSize } from "../../system/db_size.ts";
5455 import { childDirOf, listChildren } from "../../actions/spawn.ts";
5556 import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts";
5657
@@ -91,6 +92,44 @@ export function largestDirsSection(found: LargestDirs | null): Html {
9192 <p class="muted small">Measured ${time(found.at)}</p>`}</section>`;
9293 }
9394
95 /**
96 * The database's size (#12010). On first view, the heading and one sentence:
97 * the file's size and its growth a day, with a badge when either is worth a
98 * look (as the off-site section keeps its state on first view, #11805).
99 * Behind the disclosure, the facts as `dl` rows (file, WAL, room inside,
100 * growth), then one table of the ten largest tables (rows, bytes) and the ten
101 * largest indexes (the table each serves, bytes). The walk is at most an hour
102 * old and never waited on.
103 */
104 export function dbSizeSection(size: DbSize): Html {
105 const w = size.walk;
106 const g = size.growth;
107 const grows =
108 g.perDay === null
109 ? "growth needs a day of readings"
110 : `${g.perDay < 0 ? "shrinks" : "grows"} ${bytes(Math.abs(g.perDay))} a day over ${String(g.days)} days`;
111 const said = dbSizeNeed(size.fileBytes, g);
outdated

Principle 7: these four facts (file, WAL, free, growth) are pills; make them a dl of rows.

112 const fact = (name: string, value: string): Html => html`<div class="status-row"><dt>${name}</dt><dd>${value}</dd></div>`;
113 const state = html`forge.db is ${bytes(size.fileBytes)}; ${grows}
114 ${said === undefined ? null : badge(said.cause === "db-size" ? `past ${bytes(DB_FILE_CAP)}` : `over ${bytes(DB_GROWTH_MAX)} a day`, { tone: "attn" })}`;
115 return html`<section class="settings-block" id="sys-db"><h2>Database size</h2>
116 ${disclosure(state, html`<dl class="status-rows" id="sys-db-summary" data-bytes="${String(size.fileBytes)}" data-per-day="${g.perDay === null ? "" : String(g.perDay)}">
117 ${fact("File", bytes(size.fileBytes))}
118 ${fact("WAL", bytes(size.walBytes))}
119 ${w === null ? null : fact("Free inside", bytes(w.freeBytes))}
120 ${fact("Growth", grows)}</dl>
121 ${w === null
122 ? html`<p class="muted small">Its tables are being measured; reload in a moment.</p>`
123 : html`<div class="table-scroll"><table class="table" data-dense aria-label="The ${String(TOP)} largest tables and indexes in the database">
124 <thead><tr><th scope="col">Name</th><th scope="col" class="num">Rows</th><th scope="col" class="num">Bytes</th></tr></thead>
125 <tbody><tr><th scope="colgroup" colspan="3">Tables</th></tr>
126 ${w.tables.map((t) => html`<tr data-table="${t.name}" data-bytes="${String(t.bytes)}"><td class="mono">${t.name}</td><td class="num mono">${t.rows.toLocaleString("en-US")}</td><td class="num mono">${bytes(t.bytes)}</td></tr>`)}</tbody>
127 <tbody><tr><th scope="colgroup" colspan="3">Indexes</th></tr>
128 ${w.indexes.map((i) => html`<tr data-index="${i.name}" data-bytes="${String(i.bytes)}"><td class="mono" colspan="2">${i.name} <span class="muted">on ${i.table}</span></td><td class="num mono">${bytes(i.bytes)}</td></tr>`)}</tbody>
129 </table></div>
130 <p class="muted small">Measured ${time(w.at)}, at most once an hour.</p>`}`, { id: "sys-db-detail" })}</section>`;
131 }
132
94133 /** This forge's own size and use when it is a child in a cell (#10853); nothing on any other forge. */
95134 export function selfSection(self: SelfUse | null): Html {
96135 if (self === null) return html``;
@@ -457,6 +496,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
457496 const standsOn = footing.peek() ?? declaredFooting("the cluster is being read; reload in a moment");
458497 // Never waits on the walk: the last measurement, a new one started behind it when stale.
459498 const largestUnder = largestDirs.peek(ctx.app.config.dataDir);
499 // The file's size now and the last walk of its pages; a stale walk starts a new one (#12010).
500 const dbNow = timePhase(ctx.request, "dbsize", () => dbSize.peek(ctx.app.db, ctx.app.config.dataDir, ctx.app.log));
460501 const rendering = performance.now();
461502 const r = regions(v);
462503 const at = (id: string): Html => raw(r[id]!);
@@ -487,6 +528,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
487528 <section class="settings-block"><h2>Disks and network</h2>
488529 <ul class="list" id="sys-disks">${at("sys-disks")}</ul></section>
489530 ${largestDirsSection(largestUnder)}
531 ${dbSizeSection(dbNow)}
490532 <section class="settings-block"><h2>Processes</h2>
491533 <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>
492534 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
test/db-size.test.ts added +311
@@ -0,0 +1,311 @@
1 /**
2 * The database's size (#12010): the walk of the file sizes every b-tree as
3 * SQLite's own `dbstat` would (each page counted once, overflow included),
4 * names the largest tables and indexes in order with their rows, the daily
5 * readings give growth a day, and the vitals say a file past Bun's 2 GiB
6 * map or growing faster than 64 MiB a day. /system and the MCP `system`
7 * tool (`kind: "size"`) show it to whoever reads the machine.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { Database } from "bun:sqlite";
11 import { mkdtempSync } from "node:fs";
12 import { rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { createApp, type App } from "../src/app.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import type { Ctx } from "../src/http/context.ts";
18 import { vitals } from "../src/kernel/vitals.ts";
19 import { tool as systemTool } from "../src/http/mcp/tools/system.ts";
20 import { dbSizeSection } from "../src/web/pages/system.ts";
21 import {
22 DB_FILE_CAP,
23 DB_GROWTH_MAX,
24 KEEP_DAYS,
25 dbGrowth,
26 dbSizeCache,
27 dbSizeNeed,
28 measureDbWalk,
29 noteDbSize,
30 overflowPages,
31 rootsOf,
32 walkFile,
33 type DbSize,
34 } from "../src/system/db_size.ts";
35
36 const DAY = 86_400_000;
37
38 describe("overflowPages, by hand (fileformat2 §1.6)", () => {
39 test("a payload that fits on the page spills nothing", () => {
40 // Table leaf, 4096 usable: X = 4096 - 35 = 4061.
41 expect(overflowPages(4061, 4096, true)).toBe(0);
42 // Index: X = floor(4084·64/255) - 23 = 1025 - 23 = 1002.
43 expect(overflowPages(1002, 4096, false)).toBe(0);
44 });
45
46 test("20,005 bytes in a table leaf of 4096 usable spill into 4 pages", () => {
47 // M = floor(4084·32/255) - 23 = 489; K = 489 + (19516 mod 4092) = 489 + 3148 = 3637 ≤ 4061,
48 // so 3637 stay and 16,368 = 4 × 4092 spill.
49 expect(overflowPages(20_005, 4096, true)).toBe(4);
50 // One byte past X: K = 489 + (3573 mod 4092) = 4062 > 4061, so only M = 489 stay
51 // and 3573 spill into 1.
52 expect(overflowPages(4062, 4096, true)).toBe(1);
53 });
54 });
55
56 describe("the walk of a seeded database", () => {
57 let dir: string;
58 let db: Database;
59
60 beforeAll(() => {
61 dir = mkdtempSync(join(tmpdir(), "forge-db-size-"));
62 db = new Database(join(dir, "forge.db"));
63 db.exec("PRAGMA journal_mode = WAL");
64 db.exec(`
65 CREATE TABLE big (id INTEGER PRIMARY KEY, body TEXT);
66 CREATE INDEX big_body ON big (body);
67 CREATE TABLE small (a);
68 CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
69 CREATE TABLE empty (a);
70 `);
71 const big = db.prepare("INSERT INTO big (body) VALUES (?)");
72 const kv = db.prepare("INSERT INTO kv (k, v) VALUES (?, ?)");
73 db.transaction(() => {
74 // Every seventh body is 20 KB: overflow pages the walk must count.
75 for (let i = 0; i < 3000; i++) big.run(`${"x".repeat(i % 7 === 0 ? 20_000 : 300)}${i}`);
76 for (let i = 0; i < 50; i++) db.run("INSERT INTO small VALUES (?)", [i]);
77 for (let i = 0; i < 5000; i++) kv.run(`k${i}`, "v".repeat(100));
78 })();
79 // Freed pages go to the freelist; they must not count toward any table.
80 db.run("DELETE FROM big WHERE id % 5 = 0");
81 db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
82 });
83
84 afterAll(async () => {
85 db.close();
86 await rm(dir, { recursive: true, force: true });
87 });
88
89 test("every page is one b-tree's or free, and rows are the tables' rows", async () => {
90 const r = await walkFile(join(dir, "forge.db"), rootsOf(db));
91 const pageCount = db.query<{ page_count: number }, []>("PRAGMA page_count").get()!.page_count;
92 const freelist = db.query<{ freelist_count: number }, []>("PRAGMA freelist_count").get()!.freelist_count;
93 expect(r.freePages).toBe(freelist);
94 expect(freelist).toBeGreaterThan(0);
95 expect(r.trees.reduce((n, t) => n + t.pages, 0) + r.freePages).toBe(pageCount);
96 const rows = (name: string) => r.trees.find((t) => t.name === name)!.rows;
97 expect(rows("big")).toBe(2400);
98 expect(rows("small")).toBe(50);
99 expect(rows("kv")).toBe(5000);
100 expect(rows("empty")).toBe(0);
101 expect(rows("big_body")).toBe(2400);
102 });
103
104 test("the largest come first, tables and indexes apart, with rows and their table", async () => {
105 const w = await measureDbWalk(db, dir, () => Date.parse("2026-11-13T12:00:00Z"));
106 expect(w.at).toBe("2026-11-13T12:00:00.000Z");
107 expect(w.pageSize).toBe(4096);
108 expect(w.tables.map((t) => t.name)).toEqual(["big", "kv", "empty", "small", "sqlite_schema"]);
109 expect(w.tables[0]!.rows).toBe(2400);
110 // Each body of 20 KB keeps at most 4061 bytes on its leaf and spills 4 overflow pages
111 // (by hand above).
112 const long = db.query<{ n: number }, []>("SELECT count(*) AS n FROM big WHERE length(body) > 20000").get()!.n;
113 expect(long).toBe(343);
114 expect(w.tables[0]!.bytes).toBeGreaterThan(long * 4 * 4096);
115 // A WITHOUT ROWID table's key is the table itself: no index beside it.
116 expect(w.indexes.map((i) => [i.name, i.table])).toEqual([["big_body", "big"]]);
117 expect(w.freeBytes).toBeGreaterThan(0);
118 });
119
120 test("ten of each at most", async () => {
121 for (let i = 0; i < 12; i++) db.exec(`CREATE TABLE t${i} (a); CREATE INDEX t${i}_a ON t${i} (a)`);
122 db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
123 const w = await measureDbWalk(db, dir);
124 expect(w.tables).toHaveLength(10);
125 expect(w.indexes).toHaveLength(10);
126 expect(w.tables[0]!.name).toBe("big");
127 });
128
129 test("a file that is not a database is refused, not guessed at", async () => {
130 await Bun.write(join(dir, "not.db"), "hello, this is not SQLite".repeat(10));
131 await expect(walkFile(join(dir, "not.db"), [])).rejects.toThrow("not a SQLite database");
132 });
133 });
134
135 describe("growth and the need", () => {
136 let dataDir: string;
137 let app: App;
138 const roomy = () => ({ freeBytes: 95, totalBytes: 100 });
139
140 beforeAll(() => {
141 dataDir = mkdtempSync(join(tmpdir(), "forge-db-growth-"));
142 app = createApp({ dataDir, port: 0, disk: roomy });
143 });
144
145 afterAll(async () => {
146 app.close();
147 await rm(dataDir, { recursive: true, force: true });
148 });
149
150 test("one reading a UTC day, the first kept, 60 days kept", () => {
151 const t0 = Date.parse("2026-09-01T08:00:00Z");
152 noteDbSize(app.db, dataDir, t0);
153 noteDbSize(app.db, dataDir, t0 + 3_600_000);
154 const rows = () => app.db.query<{ day: string; at: string }, []>("SELECT day, at FROM db_size_day ORDER BY day").all();
155 expect(rows()).toEqual([{ day: "2026-09-01", at: "2026-09-01T08:00:00.000Z" }]);
156 noteDbSize(app.db, dataDir, t0 + (KEEP_DAYS + 1) * DAY);
157 expect(rows().map((r) => r.day)).toEqual(["2026-11-01"]);
158 app.db.run("DELETE FROM db_size_day");
159 });
160
161 test("growth a day is the file now against the oldest reading of the last week", () => {
162 const now = Date.parse("2026-11-13T12:00:00Z");
163 const put = app.db.prepare("INSERT INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)");
164 // Ten days ago is outside the week; four days ago is the oldest inside it.
165 put.run("2026-11-03", "2026-11-03T12:00:00.000Z", 1_000);
166 put.run("2026-11-09", "2026-11-09T12:00:00.000Z", 100_000_000);
167 put.run("2026-11-12", "2026-11-12T12:00:00.000Z", 200_000_000);
168 const g = dbGrowth(app.db, 500_000_000, now);
169 // (500,000,000 - 100,000,000) / 4 days.
170 expect(g.perDay).toBe(100_000_000);
171 expect(g.days).toBe(4);
172 expect(g.readings.map((r) => r.day)).toEqual(["2026-11-03", "2026-11-09", "2026-11-12"]);
173 // Eight hours after the oldest reading, under 20 hours, says nothing yet.
174 expect(dbGrowth(app.db, 1, Date.parse("2026-11-03T20:00:00Z"))).toMatchObject({ perDay: null, days: 0 });
175 });
176
177 test("the need fires past the 2 GiB map or 64 MiB a day, the size first", () => {
178 expect(DB_FILE_CAP).toBe(2 * 1024 ** 3 - 64 * 1024);
179 expect(dbSizeNeed(DB_FILE_CAP, { perDay: DB_GROWTH_MAX, days: 7 })).toBeUndefined();
180 expect(dbSizeNeed(DB_FILE_CAP + 1, { perDay: null, days: 0 })?.cause).toBe("db-size");
181 expect(dbSizeNeed(4.5 * 1024 ** 3, { perDay: 10 * DB_GROWTH_MAX, days: 7 })).toEqual({
182 cause: "db-size",
183 sentence: "The database is 4.5 GiB, past the 2 GiB Bun maps; /system names its largest tables.",
184 });
185 expect(dbSizeNeed(1024 ** 3, { perDay: DB_GROWTH_MAX + 1, days: 3.5 })).toEqual({
186 cause: "db-growth",
187 sentence: "The database grows 64 MiB a day over 3.5 days, past the 64 MiB a day worth a look; /system names its largest tables.",
188 });
189 });
190
191 test("the vitals carry the growth need to /system, and drop it when growth is quiet", () => {
192 const now = Date.now();
193 const cause = () => vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, now }).degraded.find((d) => d.cause.startsWith("db-"));
194 app.db.run("DELETE FROM db_size_day");
195 expect(cause()).toBeUndefined();
196 // A reading two days ago, 300 MiB smaller than nothing: the file "grew" past 64 MiB a day.
197 const then = now - 2 * DAY;
198 app.db.query("INSERT INTO db_size_day (day, at, bytes) VALUES (?, ?, ?)").run(new Date(then).toISOString().slice(0, 10), new Date(then).toISOString(), -300 * 1024 ** 2);
199 const said = cause();
200 expect(said?.cause).toBe("db-growth");
201 expect(said?.href).toBe("/system");
202 app.db.run("DELETE FROM db_size_day");
203 expect(cause()).toBeUndefined();
204 });
205
206 test("the cache never waits on a peek, and shares one walk", async () => {
207 let walks = 0;
208 let finish: () => void = () => {};
209 const cache = dbSizeCache(async (db, d) => {
210 walks++;
211 await new Promise<void>((r) => (finish = r));
212 return measureDbWalk(db, d);
213 });
214 const first = cache.peek(app.db, dataDir);
215 expect(first.walk).toBeNull();
216 expect(first.fileBytes).toBeGreaterThan(0);
217 expect(cache.peek(app.db, dataDir).walk).toBeNull();
218 expect(walks).toBe(1);
219 finish();
220 const read = await cache.read(app.db, dataDir, 5_000);
221 expect(walks).toBe(1);
222 expect(read.walk?.tables.length).toBe(10);
223 expect(cache.peek(app.db, dataDir).walk).toBe(read.walk);
224 });
225
226 test("a walk that fails is said in the log, not swallowed, and the page keeps no walk", async () => {
227 const said: { message: string; fields: Record<string, unknown> | undefined }[] = [];
228 const log = { warn: (message: string, fields?: Record<string, unknown>) => void said.push({ message, fields }) };
229 const cache = dbSizeCache(async () => {
230 throw new Error("not a SQLite database");
231 });
232 const read = await cache.read(app.db, dataDir, 5_000, log);
233 expect(read.walk).toBeNull();
234 expect(said).toEqual([{ message: "database size walk failed", fields: { dataDir, error: "not a SQLite database" } }]);
235 });
236
237 test("the MCP system tool answers kind size to an admin and refuses a member", async () => {
238 const make = async (username: string, isAdmin: boolean) => {
239 const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin });
240 if (!r.ok) throw new Error(r.error);
241 return r.value;
242 };
243 const admin = await make("size-keeper", true);
244 const member = await make("size-reader", false);
245 const ctxOf = (user: User): Ctx => ({
246 app,
247 request: new Request("http://localhost/mcp", { method: "POST" }),
248 url: new URL("http://localhost/mcp"),
249 params: {},
250 auth: { user, scope: "rw", via: "token" },
251 theme: undefined,
252 });
253 const read = (await systemTool.run(ctxOf(admin), admin, { kind: "size" })) as { ok: boolean; value: DbSize };
254 expect(read.ok).toBe(true);
255 const body = read.value;
256 expect(body.fileBytes).toBeGreaterThan(0);
257 expect(body.growth.readings).toEqual([]);
258 expect(body.walk === null || body.walk.tables.length === 10).toBe(true);
259 expect(JSON.stringify(await systemTool.run(ctxOf(member), member, { kind: "size" }))).toContain("forbidden");
260 });
261 });
262
263 describe("/system's Database size", () => {
264 const size: DbSize = {
265 fileBytes: 3 * 1024 ** 3,
266 walBytes: 4 * 1024 ** 2,
267 growth: { perDay: 10 * 1024 ** 2, days: 7, readings: [] },
268 walk: {
269 at: "2026-11-13T12:00:00.000Z",
270 pageSize: 4096,
271 freeBytes: 30 * 1024 ** 2,
272 tables: [
273 { name: "event", bytes: 2 * 1024 ** 3, rows: 1_234_567 },
274 { name: "<run>", bytes: 1024 ** 2, rows: 3 },
275 ],
276 indexes: [{ name: "event_at", table: "event", bytes: 512 * 1024 ** 2 }],
277 },
278 };
279
280 test("one table, tables then indexes in the order given, escaped, with the file and its growth", () => {
281 const out = String(dbSizeSection(size));
282 expect(out).toContain("<h2>Database size</h2>");
283 expect(out.match(/<table/g)).toHaveLength(1);
284 expect(out.indexOf('data-table="event"')).toBeLessThan(out.indexOf('data-table="&lt;run&gt;"'));
285 expect(out.indexOf('data-table="&lt;run&gt;"')).toBeLessThan(out.indexOf('data-index="event_at"'));
286 expect(out).not.toContain("<run>");
287 expect(out).toContain("1,234,567");
288 expect(out).toContain("forge.db is 3.0 GiB; grows 10 MiB a day over 7 days");
289 expect(out).toContain("past 2.0 GiB");
290 });
291
292 test("the facts are dl rows, not pills, and an index row has no filler cell", () => {
293 const out = String(dbSizeSection(size));
294 expect(out).toContain('<dl class="status-rows" id="sys-db-summary"');
295 for (const [dt, dd] of [["File", "3.0 GiB"], ["WAL", "4.0 MiB"], ["Free inside", "30 MiB"], ["Growth", "grows 10 MiB a day over 7 days"]])
296 expect(out).toContain(`<dt>${dt}</dt><dd>${dd}</dd>`);
297 expect(out).not.toContain('class="pill');
298 expect(out).not.toContain("—");
299 const index = out.slice(out.indexOf('data-index="event_at"'), out.indexOf("</tr>", out.indexOf('data-index="event_at"')));
300 expect(index.match(/<td/g)).toHaveLength(2);
301 expect(index).toContain('colspan="2"');
302 });
303
304 test("while the first walk runs it says so, and growth waits for a day of readings", () => {
305 const out = String(dbSizeSection({ ...size, fileBytes: 1024, walk: null, growth: { perDay: null, days: 0, readings: [] } }));
306 expect(out).toContain("being measured");
307 expect(out).toContain("growth needs a day of readings");
308 expect(out).not.toContain("<table");
309 expect(out).not.toContain("past ");
310 });
311 });
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 "Largest under the forge's data",
55 "Database size",
5556 "Processes",
5657 "What the forge stands on",
5758 "Off-site backup",
test/timers-named.test.ts +1
@@ -127,6 +127,7 @@ const ALLOWED: readonly Allowed[] = [
127127 ["src/snapshot/lanes.ts", "() => reject("],
128128 ["src/system/footing.ts", "() => r(null)"],
129129 ["src/system/largest_dirs.ts", "() => r(null)"],
130 ["src/system/db_size.ts", "() => r(null)"],
130131 ["src/release/devtools.ts", "() => { if (pending.delete(n))"],
131132 ["src/release/devtools.ts", "() => { waiting.set(event"],
132133 ["src/release/prove.ts", "() => resolve(\"hung\")"],
test/tui-system.test.ts +69 −1
@@ -16,7 +16,8 @@ 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 { forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
19 import { dbSizeLines, dbSizeScreen, forgeLines, historyLines, largestLines, readerLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts";
20 import type { DbSize } from "../src/system/db_size.ts";
2021 import { changeCapacity } from "../src/actions/capacity.ts";
2122 import { EGRESS_REPORTED } from "../src/system/egress_line.ts";
2223 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -140,6 +141,73 @@ describe("tui system largest directories (#11800)", () => {
140141 });
141142 });
142143
144 describe("tui system database size (#12010)", () => {
145 const size: DbSize = {
146 fileBytes: 3 * 1024 ** 3,
147 walBytes: 4 * MiB,
148 growth: { perDay: 10 * MiB, days: 7, readings: [] },
149 walk: {
150 at: new Date(Date.now() - 3 * 60_000).toISOString(),
151 pageSize: 4096,
152 freeBytes: 30 * MiB,
153 tables: [{ name: "event", bytes: 2 * 1024 ** 3, rows: 1_234_567 }],
154 indexes: [{ name: "event_at", table: "event", bytes: 512 * MiB }],
155 },
156 };
157
158 test("an admin and a granted agent read the file, its growth, the largest tables and indexes", () => {
159 const lines = dbSizeLines(new Session(app, identity(carol)), size);
160 expect(lines[0]).toBe("Database size");
161 const said = lines.join(" ").replace(/\s+/g, " ");
162 expect(said).toContain("forge.db 3.0 GiB, WAL 4.0 MiB, 30 MiB free inside; grows 10 MiB a day over 7 days");
163 expect(said).toContain("past the 2 GiB Bun maps");
164 const table = lines.find((l) => l.startsWith("event (1,234,567 rows)"));
165 expect(table).toContain("2.0 GiB");
166 expect(lines.find((l) => l.startsWith("event_at on event"))).toContain("512 MiB");
167 expect(lines.at(-1)).toBe("measured 3m ago, at most once an hour");
168 expect(dbSizeLines(new Session(app, identity(carol)), { ...size, walk: null }).at(-1)).toContain("being measured");
169 const brief = dbSizeLines(new Session(app, identity(carol)), size, true);
170 expect(brief[0]).toBe("database 3.0 GiB, grows 10 MiB a day · d: its largest tables");
171 expect(brief.join(" ")).toContain("past the 2 GiB Bun maps");
172 expect(brief.some((l) => l.includes("event_at"))).toBe(false);
173 setTelemetryGrant(app, carol, "sysbot", true);
174 try {
175 expect(dbSizeLines(new Session(app, identity(bot)), size)[0]).toBe("Database size");
176 } finally {
177 setTelemetryGrant(app, carol, "sysbot", false);
178 }
179 });
180
181 test("a member, an ungranted agent and a guest read nothing, brief or whole", () => {
182 for (const who of [bob, bot, undefined])
183 for (const brief of [false, true]) expect(dbSizeLines(new Session(app, identity(who)), size, brief)).toEqual([]);
184 });
185
186 test("the System dialog says it in brief from the shared cache, and d opens the whole", async () => {
187 const terminal = headlessTerminal(120, 80);
188 const { app: tui, session } = createForgeApp(app, identity(carol), { terminal, exitHooks: false });
189 tui.start();
190 tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session));
191 await settleApp(tui);
192 expect(tui.screen.toText()).toContain("d: its largest tables");
193 tui.dispatch({ type: "key", key: "d", ctrl: false, alt: false, shift: false });
194 await settleApp(tui);
195 expect(tui.screen.toText()).toContain("Tables");
196 expect(tui.screen.toText()).toContain("sqlite_schema");
197 tui.stop?.();
198 });
199
200 test("the whole screen is the reader's only", async () => {
201 const terminal = headlessTerminal(120, 40);
202 const { app: tui, session } = createForgeApp(app, identity(bob), { terminal, exitHooks: false });
203 tui.start();
204 tui.ctx.push(dbSizeScreen(session, tui.ctx));
205 await settleApp(tui);
206 expect(tui.screen.toText()).not.toContain("forge.db");
207 tui.stop?.();
208 });
209 });
210
143211 describe("tui system history", () => {
144212 const put = (at: string, cpu: number, mem: number, load: number) =>
145213 app.db
test/vitals.test.ts +6 −2
@@ -171,8 +171,12 @@ test("the number of queries does not grow with runs", () => {
171171 if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id);
172172 }
173173 expect(count()).toBe(before);
174 // The queue, the running runs, the runs killed twice for their budget, the lane's depth and finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks red on a full disk only while the disk is full), and the newest drill of every domain, the checks no red main holds, and the week's flake list.
175 expect(before).toBe(10);
174 // The queue, the running runs, the runs killed twice for their budget, the lane's depth and
175 // finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks
176 // red on a full disk only while the disk is full), and the newest drill of every domain, the
177 // checks no red main holds, the week's flake list, and the database's daily size readings
178 // (#12010, at most 60 rows).
179 expect(before).toBe(11);
176180 });
177181
178182 test("errors are counted, bounded, redacted, and a burst is said", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs