Public: anyone can read this; the crew can change it.
Boards: the forge samples its own pod and node every 15 s into a ring on disk, as a machine source #1791
Check · Review · Land · Live done — Landed and live in production.
The forge now records how much CPU and memory its own pod, each check and app cell, and the node use, every 15 s. Boards query this as a new `machine` source. The type check passes, and the boards, schema, migration and containment suites pass (the cell suites skipped as usual in this sandbox).
shipped production is serving it deploy 2da791d
Security holds. The machine rows are reachable only through the boards surfaces, which are already limited to a site admin who is a person (boardsAdmin, and {repoIds: "all"} on every caller). runQuery returns nothing to a narrower scope, and a test covers that. The new where and the strftime subquery take no input. The ring is bounded by time and rolled up in one transaction. A missing file leaves its series empty rather than zero, and the page names that file. On head 910954a, bun x tsc --noEmit is clean and the boards-sampler, boards-page and boards-query suites pass (51 tests). Boards have no terminal or MCP door on main, and this pull adds a source to the existing page and API, which is fine.
One miss, and it is the first thing to fix: the per-cell series has no test that would fail without it. Every sampler test passes cells: () => [...] by hand. Nothing checks that a real cell shows up in cellCounters(): not the label threaded through CellSpec → watchTree, not entry.last being set in tick(), and not check:<run id> or app:<slug>/<env> being passed from checks/service.ts, apps/runtime.ts and stepCell. You could delete entry.last = { ticks, kib } or the label: base.label in stepCell and the suite would stay green.
To fix it, add a test to the cell suite the forge check runs (it is skipped in the sandbox, which is fine). Open a cell with label: "check:t1" running something that holds memory for a couple of watch ticks. Check that cellCounters() has one entry with that label, memoryBytes > 0 and cpuUsec >= 0. Check that it is gone once the cell ends, and that an unlabelled cell never appears. If the checks service has a seam for it, also assert that a check run's cell carries check:<run id>.
I could not see the preview or the screenshots: this sandbox refuses the preview host and /data/looks. So the visual check rests on the page test, which renders the gaps card, the column docs and a drawn forge row.
The forge's machine sampler records CPU and memory every 15 s for the forge pod, each check and app cell, and the node. It keeps 15-s points for 24 h and 5-min mean and max for 30 days, and boards query it as a new machine source.
Review fix (head 0ad1291e): the per-cell series now has tests that use real cells instead of an injected cells list. Two of the three new tests need real cells, so they were skipped here and only the forge check runs them.
- Labelled cells reach the sampler: a new test in
test/contain.test.ts(in "cells: limits stop a step") runs a cell labelledcheck:t1and an unlabelled one side by side, each holding 64 MiB. It readscellCounters()throughout. Onlycheck:t1ever shows up, with more than 64 MiB of memory andcpuUsec >= 0, and it is gone once the cell ends. Deletingentry.last = { ticks, kib }or thelabelpassed intowatchTreemakes it fail. - Real deploys and checks carry their label: the end-to-end cell suite records every label
cellCounters()reports while its apps and check run, with the watch ticking every 200 ms there. A new test asserts the labels includeapp:ledger/production,app:probe/productionandcheck:<id>of a real check run, and that every label has one of those two shapes. This coverschecks/service.tsandapps/runtime.ts; the checks service has no other place to observe the cell spec from a test. - Deploy steps:
stepCellis now exported, and a plain test checks that a deploy's install and test steps carry the environment's label. This one runs everywhere. I removedlabel: base.labelfromstepCelland confirmed the test fails.
The rest of the pull is as before:
src/boards/sampler.tsreads the pod's cgroup v2 (cpu.statusage_usecas a rate,memory.current), each labelled cell's process tree, and the node from/proc. Per-cell cgroups can't be created inside the container, so cells are read from/procinstead.src/boards/ring.tsstores the points in themachine_sampletable, which survives a restart.- A file this machine lacks leaves its series empty, never zero, and the source's page names that file.
- Only a scope covering every repo can read the
machinerows, because they belong to no repo.
To try it, boot the forge, wait 30 s, and as a site admin open /boards/data?source=machine&aggregate=avg&column=cpu_cores&by=target.
🤖 Generated with Claude Code
The forge's machine sampler records CPU and memory every 15 s for the forge pod, each check and app cell, and the node. It keeps 15-s points for 24 h and 5-min mean and max for 30 days, and boards query it as a new machine source.
Merge with main (head 9164c286): the only conflict was in src/core/schema.ts. Both sides had added a migration 87. main's 87 (thought_link and issue_raise, #654) stays as it is, and machine_sample follows it as migration 88, unchanged. The type check is clean, and the boards, schema, migration and containment suites pass (104 pass, 0 fail). The 26 that need real cells were skipped in this sandbox, including the two cell tests added after review, so they first run in the forge check.
What the pull does:
src/boards/sampler.tsreads the counters every 15 s:
- the pod from its cgroup v2 (cpu.stat usage_usec as a rate, and memory.current);
- each check and app cell from its process tree under /proc, because per-cell cgroups can't be created inside the container;
- the node from /proc/stat, /proc/meminfo and /proc/loadavg.
A file this machine lacks leaves its series empty, never zero, and the source's page names that file.
src/boards/ring.tsstores the points in themachine_sampletable, so they survive a restart. Each write rolls up closed 5-min windows and drops points that have aged out.- Cell labels: cells are labelled
check:<run id>orapp:<slug>/<env>, and the cell suites test that real cells show up under those labels. - Access: these rows belong to no repo, so only a scope covering every repo can read them.
To try it, boot the forge, wait 30 s, and as a site admin open /boards/data?source=machine&aggregate=avg&column=cpu_cores&by=target.
🤖 Generated with Claude Code
Approved. This does what #1576 asks. sampler.ts reads the pod's cgroup v2, each labelled cell's process tree and the node's /proc files. ring.ts keeps 15-s points for 24 h and 5-min mean/max for 30 d in machine_sample, bounded by pruning on every write. The machine source documents every column with its unit. A file that is missing gives an absent series, named on the page, never a zero.
Security: every route that reaches the source already gates on a site admin: /boards/data refuses agents, and /api/v1/boards/* goes through boardsAdmin. Rows that belong to no repo return nothing to a repo-scoped query. No user text reaches SQL: the new where is a constant, and the docs are escaped through html. Nothing new reads a request body. The sampler only reads fixed paths.
Tests: they use a hand-calculated fake cgroup and /proc tree, an exact roll-up, ring pruning and bounds, and real labelled cells in the contain suites. The page test covers the gaps card. Reading /proc trees instead of a cgroup per cell is justified, since a container cannot make cgroups. Boards has no terminal door yet, and this pull adds a source, not an action, so that is not this pull's to add.
I could not reach the preview or the screenshots from my sandbox. This verdict rests on the diff and its tests.
The two line notes are low and can wait for a follow-up.
Boards (#1576): the forge now samples its own pod, cells and node every 15 s into a ring buffer, and serves it as the machine source. This update merges the latest main into the branch.
The only conflict was the import block of src/web/pages/boards.ts. Main's boards work (#1818, #1785) moved the page from lineChart to drawChart, which handles every panel type. The merged file keeps main's chart, query and database imports and adds back this branch's gaps, SYSTEM import from boards/sampler.ts. That import feeds the "What this machine measures" section on the machine source.
The rest merged on its own:
- The source list carries main's new
serieslink parameter. - This branch's per-column docs still show for the selected source.
machine_sampleis still migration 88, because main added no migrations since the last merge.
The type check passes. The boards and sampler tests pass (87, 0 fail), and so do the migration tests (13, 0 fail).
To try it, open /boards on the preview and choose the machine source. Its column docs and the list of what the machine can't measure appear under the source list, and its chart is drawn with main's new panel types.
🤖 Generated with Claude Code
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The full gate on land was red and named no test; the pull is open again.
The forge now records how much CPU and memory its own pod, each check and app cell, and the node use, every 15 s. Boards query this as a new
machinesource. The type check passes, and the boards, schema, migration and containment suites pass (the cell suites skipped as usual in this sandbox).One difference from the issue: check and app cells are measured from their process trees under
/proc, not from per-cell cgroups. A container can't make a cgroup for each cell, so there's nocpu.statto read for one. Each cell's CPU (user + system time, as a rate) and resident memory come from the watch that already enforces its limits inapps/contain.ts. That watch now keeps its last reading, taggedcheck:<run id>orapp:<slug>/<env>. The forge pod still uses its cgroup v2cpu.statusage_usecas a rate, plusmemory.current.src/boards/sampler.tsreads the counters every 15 s and turns two readings into cores and bytes. The node's figures come from/proc/stat(busy time),/proc/meminfo(MemTotal − MemAvailable) and/proc/loadavg. When a file is missing, that series is left empty rather than set to zero, and a CPU counter that goes backwards gives no rate. The source's page lists each missing series and the file it needs.src/boards/ring.tsstores the samples in a SQLite table,machine_sample(migration 87), so history survives a restart. It keeps 15-s points for 24 h and 5-min points (mean and max of the 15-s points) for 30 days. Each write rolls up the 5-min windows that have closed and deletes what has aged out, so the table never grows past its window.machineinSOURCEShas the columnstarget,at,step,cpu_cores,cpu_cores_max,memory_bytes,memory_bytes_maxandload. Each column's unit is documented in a newdocsfield, which/api/v1/boards/sourcesreturns and/boards/datashows. A query never counts the same moment twice: 5-min points that overlap the 15-s points are left out.owner: "admin").runQueryreturns no rows for any narrower scope.docs/APPS.mddescribes the source.Tests in
test/boards-sampler.test.ts(added to the gate):/proctree gives hand-calculated rates: 0.2 cores for the pod, 0.24 for the node, 0.1 for a cell.test/boards-page.test.tscovers the page.Left out: no new chart or board for this source; it uses the existing query page and pins. The node's CPU assumes
USER_HZ= 100.To try it, boot the forge, wait 30 s, and as a site admin open
/boards/data?source=machine&aggregate=avg&column=cpu_cores&by=target.🤖 Generated with Claude Code
Closes #1576