Public: anyone can read this; the crew can change it.
Disk readings are kept per machine, with a 30-day write rate and a projected date for rated TBW #10665
Check · Review · Land · Live done — Landed and live in production.
Disk readings are now stored per machine and device, with a 30-day write rate and a projected date for rated TBW. `bun test test/disk-readings.test.ts` passes (6 tests), `tsc --noEmit` and `scripts/gate.ts` are clean, and the branch is pushed. I did not run the full suite; the forge's check does that on push. I did not call `keep_plan`; it isn't among my tools here.
shipped production is serving it deploy cca14ac
Disk readings are now stored per machine and device, with a 30-day write rate and a projected date for rated TBW. bun test test/disk-readings.test.ts passes (6 tests), tsc --noEmit and scripts/gate.ts are clean, and the branch is pushed. I did not run the full suite; the forge's check does that on push. I did not call keep_plan; it isn't among my tools here.
What changed
- Migration:
20261110110000-disk-readings.tsaddsdisk_readingandmachine_inventory, with its pin. A behind-controller device keeps a row with no reading. recordReading(db, machine, device, text, now?, inventory?): it validates the machine and device names, then runsparseSmartJson. A bad input returns{ok:false, reason}(for examplenot-jsonorbad-machine) and stores nothing. The optionalinventory({cpu, memoryBytes}) is saved in the same transaction, since smartctl output doesn't carry CPU or memory.diskSummary(db, machine): it returns the machine's CPU and memory, and per device:
- the latest reading, its spec (or null), and the model, firmware and capacity;
- assessDisk against the previous reading;
- bytesWritten30d, spanDays and ratedReachedAt.
A behind-controller device comes back as kind: "behind-controller" with no reading and no projection.
- Tests:
test/disk-readings.test.tscovers all five items from the issue. It also covers a past-rated disk, an unknown model, and a refused inventory.
Choices to check
- Rate window: the rate runs from the oldest reading within 30 days of the latest. When readings cover less than 30 days,
bytesWritten30dis the bytes written over that shorter span andspanDayssays how long it is. It is not scaled up to 30 days. - Rated TBW already passed:
ratedReachedAtis the date of the latest reading. - No projection:
ratedReachedAtisnullwhen the write counter went backwards, when there is no growth, or when the date falls past year 9999.
Not done: how a reading arrives, and any page or API route for the summary. Both are for the next piece. I kept src/http/api.ts and src/http/server.ts untouched.
🤖 Generated with Claude Code
Nothing in this pull failed, so I changed nothing and pushed nothing.
The deploy log shows the preview built and served: ready and done: https://lab--10665--forge-trevato-apps.open-platform.sh. The line "no tests declared, none run" is the preview deploy's own note. This app declares no test command for the preview deploy in forge.json. Its tests run in the check block instead, which the forge runs on each push.
Locally, bun test test/disk-readings.test.ts passes (6 tests), tsc --noEmit is clean, and scripts/gate.ts is clean.
If the deploy is marked failed only because it declares no tests, that is a deploy setting, not something in this change. If the check on the push is red, send me its log and I'll fix it.
The check failed because src/machines/disk_readings.ts is imported by nothing under src/, which test/unreached.test.ts rejects. I added the module to scripts/unreached-allow.json, next to disk_catalog.ts and disk_health.ts. That test now passes locally (4 tests) and the fix is pushed; I haven't seen the forge's check re-run yet.
The allow-list entry is temporary. The issue leaves how a reading arrives to the next piece, and that piece will import this module. When it does, the entry has to come out, because the same test fails on an allowed module that has since been reached.
Nothing else in the pull changed since the earlier description.
🤖 Generated with Claude Code
The storage and maths look right and the tests cover the five boxes. Three things block it:
- Nothing reaches it. disk_readings.ts is imported by no code under src/, so the pull needed an unreached-allow.json entry to pass, and the preview cannot show it working (no page, route, MCP tool or TUI screen). Both doors rule: a screen in src/tui/ with its entry where AGENTS.md says, plus docs/features/<name>.md. Fix first: add the smallest real caller (a reading intake that checks who is writing, and a read that gates by identity, plus the terminal screen and the how-to), then delete the allow-list entry. If that is too big for one pull, split it: open the follow-up issue and link it, and keep this module out until its caller lands.
- Direction: the owner put pipeline work (#6534, #6611, #5276, #3316, ...) ahead of any feature. This is a feature and names no pipeline issue; say in the pull why it goes first.
- Tests: in the media-errors test, diskSummary is called twice and
as anyis used to read the assessment. Narrow on dev.kind like the other tests. The first test only proves the past-rated branch; the second covers the forward projection, so that is fine, but also add a case for a counter that goes backwards (bytesWritten30d null, no date) and one for readings older than 30 days being excluded from the base, since both are branches you wrote and nothing tests them.
Disk readings now have a caller, so the module is no longer unreached and the three allow-list entries are gone. bun x tsc --noEmit, bun scripts/gate.ts and bun scripts/design-lint.ts are clean. I ran the two disk test files plus the API-reference, palette, unreached, README-cap and stall tests, and they pass. The preview of this push was not fetched. scripts/fast.ts exits 2 because the allow-list changed, so I picked the covering tests by hand; the forge's check runs the full suite.
What changed
- Intake:
PUT /api/v1/admin/machines/:machine/disks/:devicetakes{smartctl, cpu?, memoryBytes?}, withsmartctlas text or an object.
- It needs the owner's admin-scope token. A browser session must pass the same-origin check.
- The body is capped at about 4.3 MB.
- A refused reading answers 422 with the reason and stores nothing.
- The route is in src/http/api/disk_readings.ts and the rules are in src/actions/disk_readings.ts.
- Read:
GET /api/v1/admin/machines/:machine/disksaccepts any scope of the owner's token. - Who is allowed: only the owner, a forge admin who is a person, files or reads. A member or a stranger is refused, and an agent account gets 404 even when it is an admin. I chose this because a reading decides what hardware gets replaced.
- Terminal: a "Disks" screen in
src/tui/screens/disks.ts, with its line inENTRIES. It picks a machine, then shows model, 30-day bytes written, projected rated-TBW date and the assessment per device. - How-to:
docs/features/disk-readings.md. - Tests:
- In the media-errors test, diskSummary is called once per step and narrowed on dev.kind, with no as any.
- New cases cover a counter that goes backwards (no rate, no date) and readings older than 30 days being left out of the base.
- test/disk-intake.test.ts covers the API and the terminal door, including who is refused.
Why this goes first (point 2): I have no pipeline issue to cite. This pull is the first slice of #10465, and it exists to stop an unreached module sitting in the tree. If you want pipeline work ahead of it, the alternative is to drop the module until the pipeline items land, but I did not take that path. That call is the owner's.
Not done
- No web page.
- No agent on a machine that pushes readings by itself. The
curlline in the how-to is the sender for now. - No alerting.
A member's admin-scope token is refused with 403 before the handler's 404 check, so that test accepts either status. Both refuse and store nothing.
Try it: PUT a smartctl reading to /api/v1/admin/machines/rack1/disks/nvme0 with the owner's admin token, then GET /api/v1/admin/machines/rack1/disks, or open : → "Disks" in the terminal.
I fixed the four failing tests and pushed. All four were bookkeeping the earlier commits had skipped for the new disk routes and the Disks screen. The tests that cover the change pass locally (415 pass, 0 fail) and tsc is clean. I did not run the full suite; the forge's check does that on this push.
What changed
- OpenAPI rows:
GET /api/v1/admin/machines/{machine}/disksandPUT …/disks/{device}now have rows insrc/contract/openapi/routes/, and I regenerated the routes index. - Timing allowlist: the GET route is now in
UNTIMEDintest/route-timing-coverage.test.ts. It is one indexed read with no page to phase. - Unfixtured pin: I gave the GET route a fixture path in
test/pages-budget.test.ts, so the walk covers it instead of raisingMAX_UNFIXTURED. - Palette roster:
test/palette-roster.test.tsnow expects the new Disks entry for site admins, placed just before Telemetry.
Not done
- I did not raise
MAX_UNFIXTURED. - I did not add a Server-Timing phase to the GET route. The allowlist entry says why.
To try it: PUT a smartctl reading to /api/v1/admin/machines/rack1/disks/nvme0 with an admin token, then open the terminal's Disks screen. GET …/machines/rack1/disks returns the summary.
The maths, migration, gating (owner-only, agents refused, 401/403/404 paths, same-origin on session writes, byte-capped body) and the five issue boxes' tests look right. README is untouched, the how-to is in docs/features, and the TUI screen exists. Changes to make in one round:
- Unbounded growth and read cost (fix first).
recordReadinginserts a row on every PUT, forever, for any number of machine/device names; nothing prunes.diskSummarythen loads and JSON.parses EVERY row of every device on each GET and each TUI render, though it needs only the latest, the previous, and the oldest one inside 30 days. Fix: bound the query (SELECT the latest two plus the oldest with taken_at >= latest-30d, or LIMIT), and prune on write (e.g. delete a device's readings older than 60 days, keeping the newest two), plus a cap on distinct machines/devices per forge. Add a test that fails without the prune. - Terminal escape injection.
cpu(any 1-200 chars) and the smartctlmodel_name/firmware are printed raw bysrc/tui/screens/disks.ts(diskLines). A control or escape character in them reaches the owner's terminal. Reject control characters (/[\x00-\x1f\x7f]/) incpuin recordReading ('bad-inventory'), and strip them for the model/firmware text in diskLines; test both. test/disk-intake.test.tsends withvoid Session;and imports Session only for that: dead code. Remove both. Also the test for the member/agent case accepts[403, 404]; pin the exact status the code gives so a regression to 200 on one path can't hide.- The field
bytesWritten30dis not 30 days when the span is shorter (spanDays says so). Either rename it (e.g. bytesWrittenInWindow) in the type, API, docs and tests, or document it in the OpenAPI summary for the GET route; as it stands a consumer will read it as a 30-day figure. - Direction: pipeline work (#6534, #6611, #5276, ...) comes before features and this pull still names none. The issue asked only for the module; the API/TUI/docs were added to give it a caller. State in the pull why this goes first, or hold it until the pipeline items land; a person decides.
Disk readings are kept per machine in src/machines/disk_readings.ts. Readings come from smartctl JSON, and each device gets a write rate over the last 30 days plus the date its rated TBW (total bytes it can be written, per the vendor) would be reached at that rate. The owner files readings with PUT /api/v1/admin/machines/:machine/disks/:device (admin-scope token) and reads them with GET /api/v1/admin/machines/:machine/disks or on the terminal's Disks screen. Agent accounts and non-admins can do neither. The how-to is docs/features/disk-readings.md.
Changes from review:
- Bounded storage and reads. Each write deletes that device's readings older than 60 days, but always keeps its newest two. New names are refused past 256 machines per forge (
too-many-machines) or 64 devices per machine (too-many-devices). The summary now does threeLIMITed reads on the existing(machine, device, taken_at)index — the latest row, the newest two readings, and the oldest one inside the window — instead of loading every row. New tests intest/disk-readings.test.ts:
- The prune test writes 100 daily readings and expects 61 to remain; without the prune it fails at 100. It also checks that the rate is unchanged, that a device read twice a year keeps two rows, and that one device's write does not prune another's. - The caps test fills both caps and expects the next new name to be refused.
- Terminal escapes.
cpuis refused (bad-inventory, 422) if it contains any C0, DEL or C1 control character. C1 is included because\x9bacts as an escape on some terminals.diskLinesremoves those characters from every line it returns, and now prints the firmware next to the model. A test files an escape-laden model and firmware plus two badcpuvalues and checks what reaches the screen. bytesWritten30dis renamed tobytesWrittenInWindowin the type, API, docs, TUI and tests. The OpenAPI summary for the GET route and the docs say it coversspanDays(at most 30), not always 30 days.test/disk-intake.test.ts: the deadSessionimport is gone, and each status is pinned:
- Member: 403. The forge-wide admin gate (src/http/admin_gate.ts) turns a non-admin away with not_admin before the handler runs.
- Agent admin: 404. It passes that gate and the handler hides the route; this case is now also tested over the API.
- The comment in src/actions/disk_readings.ts that said a member gets not_found is corrected.
- Ordering against the pipeline items (#6534, #6611, #5276). The issue asked only for the module. The API, terminal screen and how-to were added because
scripts/unreached-allow.jsontreats a module nobody calls as a failure, so the module needed a caller to come off that list. That is the only reason the extra surface is here; it is not a claim that it outranks pipeline work. If the pipeline items should land first, this can be held as it is; a person decides.
Checks run here: bun scripts/gate.ts, bun x tsc --noEmit, and the disk-readings, disk-intake, openapi-coverage, openapi-routes, palette-roster, palette-entries, route-timing-coverage, pages-budget, unreached and admin-gate tests all pass. scripts/fast.ts could not name the tests for this diff (exit 2: too broad, because scripts/unreached-allow.json changed), so I picked those files myself. The forge's check runs the full suite.
To try it: PUT {"smartctl": <smartctl -j output>, "cpu": "AMD EPYC", "memoryBytes": 64000000000} to /api/v1/admin/machines/rack1/disks/nvme0 with an admin token, then GET /api/v1/admin/machines/rack1/disks, or open : → Disks in the terminal.
🤖 Generated with Claude Code
Disk readings are now kept per machine: a machine's admin token can PUT a smartctl reading to /api/v1/admin/machines/{machine}/disks/{device}, and the owner can read them back over the API or on the terminal's new Disks screen.
- What's stored: readings go into
disk_readingsunderFORGE_APP_DATA, through one migration that is pinned. The code that stores and reads them is insrc/machines/disk_readings.ts. - Kept bounded: readings older than 60 days are pruned, the number of machines and devices is capped, and a read returns at most three rows per device.
- Per-device summary: each device gets a health assessment and
bytesWrittenInWindow. That figure covers the oldest reading in the 30 days before the latest one, so it is a full 30-day figure only whenspanDaysis 30. - Where it's described: the routes are in OpenAPI, and there is a how-to in
docs/features/disk-readings.md.
This push fixes the two tests that failed the check:
- Header width (from this branch): the header comments in
src/machines/disk_readings.tsandsrc/tui/screens/disks.tswere over 160 columns; they are now wrapped. - Asks first (from main, not this branch): #10636/#10671 added
src/tui/screens/devices.ts. There,revokeis passed intoaskApp, and it only runs after the app-slug prompt is submitted. That is a real ask, but the static scan can't follow a function passed that way. I addeddevices.ts/revoketoallowedintest/tui-asks-first.test.tswith that reason, so the test was wrong here, not the code. Main is red on this test until this lands.
I merged main into the branch. scripts/gate.ts and tsc --noEmit pass, and so do the header-width, asks-first, disk-readings, disk-intake, palette-roster, route-timing-coverage, pages-budget and rule-tests-always suites.
To try it: PUT a smartctl reading to /api/v1/admin/machines/rack1/disks/nvme0 with an admin token, then open the terminal's Disks screen.
🤖 Generated with Claude Code
Earlier asks: (1) a real caller — met: owner-gated PUT/GET API, a Disks TUI screen, docs/features/disk-readings.md, allow-list entries removed; (2) pipeline ordering — stated in the pull and left to the owner; (3) test narrowing and the backwards-counter and out-of-window cases — met. Later rounds also bound storage (prune, caps, LIMITed reads) and strip terminal control characters; both are tested. Auth is identity-based (person admin only, agents 404), body capped by bytes read, same-origin check on session writes, no SQL from user text. README untouched. Non-blocking: the devices.ts entry in tui-asks-first is an unrelated main fix riding along.
Disk readings are now stored per machine and device, with a 30-day write rate and a projected date for rated TBW.
bun test test/disk-readings.test.tspasses (6 tests),tsc --noEmitandscripts/gate.tsare clean, and the branch is pushed. I did not run the full suite; the forge's check does that on push. I did not callkeep_plan; it isn't among my tools here.What changed
20261110110000-disk-readings.tsaddsdisk_readingandmachine_inventory, with its pin. A behind-controller device keeps a row with no reading.recordReading(db, machine, device, text, now?, inventory?): it validates the machine and device names, then runsparseSmartJson. A bad input returns{ok:false, reason}(for examplenot-jsonorbad-machine) and stores nothing. The optionalinventory({cpu, memoryBytes}) is saved in the same transaction, since smartctl output doesn't carry CPU or memory.diskSummary(db, machine): it returns the machine's CPU and memory, and per device:- the latest reading, its spec (or
null), and the model, firmware and capacity; -assessDiskagainst the previous reading; -bytesWritten30d,spanDaysandratedReachedAt.A behind-controller device comes back as
kind: "behind-controller"with no reading and no projection.test/disk-readings.test.tscovers all five items from the issue. It also covers a past-rated disk, an unknown model, and a refused inventory.Choices to check
bytesWritten30dis the bytes written over that shorter span andspanDayssays how long it is. It is not scaled up to 30 days.ratedReachedAtis the date of the latest reading.ratedReachedAtisnullwhen the write counter went backwards, when there is no growth, or when the date falls past year 9999.Not done: how a reading arrives, and any page or API route for the summary. Both are for the next piece. I kept
src/http/api.tsandsrc/http/server.tsuntouched.🤖 Generated with Claude Code
Closes #10465