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

Merged opened this

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

Merged

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

opened this

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).

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 no cpu.stat to 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 in apps/contain.ts. That watch now keeps its last reading, tagged check:<run id> or app:<slug>/<env>. The forge pod still uses its cgroup v2 cpu.stat usage_usec as a rate, plus memory.current.

  • src/boards/sampler.ts reads 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.ts stores 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.
  • machine in SOURCES has the columns target, at, step, cpu_cores, cpu_cores_max, memory_bytes, memory_bytes_max and load. Each column's unit is documented in a new docs field, which /api/v1/boards/sources returns and /boards/data shows. A query never counts the same moment twice: 5-min points that overlap the 15-s points are left out.
  • Access: these rows belong to no repo, so only a scope covering every repo reads them (a new owner: "admin"). runQuery returns no rows for any narrower scope. docs/APPS.md describes the source.

Tests in test/boards-sampler.test.ts (added to the gate):

  • A fake cgroup and /proc tree gives hand-calculated rates: 0.2 cores for the pod, 0.24 for the node, 0.1 for a cell.
  • Missing files leave their series empty and named.
  • Twenty known samples roll up to an exact mean of 2.375 and max of 4.75.
  • A 5-min point is written only once its window has closed.
  • Old points are dropped at the edge of each window, and two days of samples stay capped at one day of 15-s points.
  • Query results and the narrow-scope rule are covered, and test/boards-page.test.ts covers 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

Changes requested — before the latest push

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.

commented

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 labelled check:t1 and an unlabelled one side by side, each holding 64 MiB. It reads cellCounters() throughout. Only check:t1 ever shows up, with more than 64 MiB of memory and cpuUsec >= 0, and it is gone once the cell ends. Deleting entry.last = { ticks, kib } or the label passed into watchTree makes 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 include app:ledger/production, app:probe/production and check:<id> of a real check run, and that every label has one of those two shapes. This covers checks/service.ts and apps/runtime.ts; the checks service has no other place to observe the cell spec from a test.
  • Deploy steps: stepCell is 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 removed label: base.label from stepCell and confirmed the test fails.

The rest of the pull is as before:

  • src/boards/sampler.ts reads the pod's cgroup v2 (cpu.stat usage_usec as 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 /proc instead.
  • src/boards/ring.ts stores the points in the machine_sample table, 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 machine rows, 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

commented

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.ts reads 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.ts stores the points in the machine_sample table, 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> or app:<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 — before the latest push

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.

commented

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 series link parameter.
  • This branch's per-column docs still show for the selected source.
  • machine_sample is 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

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

commented

The full gate on land was red and named no test; the pull is open again.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs