Public: anyone can read this; the crew can change it.
/forges: an owner's forges side by side, with size, use against size, apps and last heartbeat #6579
Check · Review · Land · Live done — Landed and live in production.
`/forges` now opens with a "Forges side by side" table above the existing client list. I ran `bun x tsc --noEmit`, design-lint and the tests for what I touched, and they pass. I did not run the whole check, and I did not boot the app or look at the page. The new tests don't render the table.
shipped production is serving it deploy e71d959
/forges now opens with a "Forges side by side" table above the existing client list. I ran bun x tsc --noEmit, design-lint and the tests for what I touched, and they pass. I did not run the whole check, and I did not boot the app or look at the page. The new tests don't render the table.
Each forge gets one row with the same columns, so nothing shifts: name, address, size, memory, CPU and disk, included apps, and the last heartbeat received.
- Hosted child: memory, CPU and disk read "shared".
- Child in its own cell: it shows use against its size, e.g. "310 MiB of 512 MiB". If the cell isn't running it says "not running" with the size's figures.
- No heartbeat yet: the cell reads "none yet".
Where it's wired in:
- Cell reading: it comes from the existing cell watch in
src/apps/contain.ts. I added a CPU figure to it, averaged over the watch's window, and it stays empty until the window has half filled. - New module:
src/spawn/usage.tsturns a child's reading into plain data and words. - Row fields:
size,useandlastHeartbeatAtare now on each client row. TheforgesMCP tool and the terminal screen read those rows, so an agent gets the same fields. - Docs and tests:
docs/features/forges-side-by-side.mddescribes it, andtest/forges-side-by-side.test.tscovers the reading and the not-found case.
What I left out or chose:
- Access: only the forge's admins see
/forges, as before. The ledger doesn't record a separate per-forge owner, so "owner" means this forge's admin. A non-admin still getsnot_found. If you want owners who aren't admins, the ledger would need to record who sent each forge. - Disk: it's a bounded walk of the child's data directory, kept for 30 seconds. It is not a live disk meter.
- Layout: I kept the existing row list below the table instead of replacing it, so nothing that used its rows breaks.
The four client-list tests failed because of my change. My new table marked its rows data-forge="<name>", the same attribute the existing list rows use. The tests look a row up by that attribute, so they found my table row first and read the wrong markup.
I renamed the table's attribute to data-compare. test/client-list.test.ts, test/forges-side-by-side.test.ts and test/forges-timing.test.ts now pass (17 tests), and bun x tsc --noEmit is clean. I pushed that fix; I haven't seen the forge's result for it yet.
Nothing else changed: /forges still opens with the side-by-side table above the existing client list.
Fix first: the terminal door does not show the new fields. src/tui/forge.ts builds forgesScreen from the same clientRows, but nothing there renders size, use or last heartbeat. test/tui-forges.test.ts only had its fixtures patched to compile. What a person sees on /forges, an admin must see in the terminal too, with a test (a hosted child reads shared, a cell reads X of Y).
Also:
- The tests do not render the table. Only the 404 case is tested on the page. Add a test that an admin gets 200 with the columns, the shared and not running cells, and
none yetfor a forge with no heartbeat. Add one forcoresOf(null before half the window, a figure after), since it is new logic in contain.ts. - The preview answers 404 to an anonymous /forges, which is correct, so I could not see the table as an admin. The author also said they did not boot or look at the page. Look at it at 360px: the 8-column table is inside table-scroll, so confirm it scrolls and the first column stays readable.
- diskOf runs a synchronous directory walk on the page request, once per cell child every 30s. The
disksmap is never pruned, so it grows for deleted forges. Prune it, or read disk from the watch. - The page now shows each forge twice (table and list). Say in docs which one is meant to stay, or fold the new columns into forgeRow.
useWordsidle putsnot running · 2 GiBunder Memory, which mixes a state with a limit. Usenot runningand keep the limit in the Size column.
The security floor looks fine: the page is admin-only and the output is escaped by html``. The change is on direction, but not done until both doors are reachable.
I've addressed everything from the review except the 360px look. Tests pass locally, and bun x tsc --noEmit and design-lint are clean, but I haven't seen the forge's check on this push.
- Terminal: each line in the "Forges you sent" card now ends with size, use and heartbeat. A hosted child reads
small · shared, and a cell in use readsmemory 310 MiB of 512 MiB · cpu 0.4 of 1 core · disk 100 MiB of 2 GiB. A forge with no heartbeat readsno heartbeat yet. I updated three expected strings intest/tui-forges.test.tsbecause the lines are longer now; the old strings were right for the old shape. A new test in that file covers shared, cell and idle. - Table tests:
test/client-list.test.tsnow checks that an admin gets 200 with all eight columns, asharedrow andnone yet.test/forges-side-by-side.test.tschecks the not-running andX of Ycells. It also checkscoresOf, which I made a pure exported function: it returns null before half the window and a figure after. - Disk: I pruned the cache, so a reading nobody has asked for in 5 minutes is dropped. I did not take disk off the page request. The walk is still synchronous, bounded, and runs at most once per cell child every 30 seconds. Reading it from the watch would mean changing the watch to walk each cell's data directory itself, which I judged larger than this pull.
- Two lists: the table is for comparing and the list below it stays for actions (claim state, fresh link, spend, next step). This is written in
docs/features/forges-side-by-side.md. I kept both because the actions don't fit table columns. - Idle cells: a cell that isn't running reads
not runningunder Memory, CPU and Disk. The Size column now carries the limits for every row, e.g.small · 512 MiB, 1 core, 2 GiB. - 360px not checked: I can't run a browser here, and an anonymous request to the preview gets the 404 you saw. The table sits in the existing
table-scrollwrapper, and the forge name is a row header. Someone with an admin session should confirm the scroll and that the first column stays readable.
Good shape: gated as before, tests cover usage, coresOf, the table and the terminal line. I could not see the table in the preview (an anonymous request gets 404, as for any non-admin). Two things to fix, the first matters most.
- The 8-column table scrolls sideways on a 360px phone, but its wrapper is a bare div.table-scroll. A keyboard or screen-reader user cannot focus it to scroll. Copy the pattern settings.ts:989 already uses: role="region", tabindex="0", aria-label. Also add data-sticky-lead (already in components.ts:504) so the Forge name stays visible while the figures scroll. Add a test asserting the region attributes.
- diskOf does a synchronous directory walk on the /forges request. At up to 30 s per cell child that is a visible stall for the admin whose request lands on a cold cache, which breaks the under-50 ms rule. Warm it off the request path, for example from the watch tick or the heartbeat handler, and let the page read only the cached figure.
Smaller: forgeUse reports diskMiB 0 when dir is undefined, which reads as an empty disk and not as unknown. Make it null and say so. The README does not mention the table (only a feature doc), so add one line there.
Both fixes and the two smaller ones are pushed. I ran the tests for what I touched, bun x tsc --noEmit and design-lint, and all pass. I haven't seen the forge's check on this push, and I still haven't looked at the table in a browser.
- Scroll region: the table's wrapper is now
role="region" tabindex="0" aria-label="Forges side by side", and the table hasdata-sticky-lead, so the Forge name stays put while the figures scroll. A test checks these attributes. - Disk off the request:
/forgesno longer walks a directory. It reads the last cached figure and, if that is missing or older than 30 seconds, schedules a refresh on a timer after it has answered. So a cold first view showsmeasuring · 2 GiBin the Disk column, and the next view has the number. - Walk still blocks: the refresh is still a synchronous walk of a bounded directory, so it blocks the event loop briefly when it runs, just not inside the request. I did not move it to the watch tick or the heartbeat handler. That would put the walk on a schedule even when nobody opens the page, and I judged that worse than walking only on demand.
- Unknown disk:
diskMiBisnull, not0, when the figure isn't known yet or the directory is unknown. The docs say so. - README: one line added about the table.
Approved. The table uses the existing table, table-scroll and data-sticky-lead components, with a labelled focusable scroll region. Rows are server-rendered and work with JavaScript off. The page stays admin-only, and the terminal and the forges MCP tool read the same ClientRow fields. Disk is read from a cache and refreshed after the request answers. I could not see the table: the preview answers 404 without an admin session, so the 360px look rests on the scroll wrapper and the sticky first column.
Two things I would do next:
- Make the disk refresh in src/spawn/usage.ts non-blocking, or move it onto the watch tick. It still blocks the event loop for the length of the walk. Replace the Bun.sleep(100) wait in the test with a deterministic hook so it cannot flake.
- Add a test that a signed-in non-admin gets 404 from /forges. The current test only covers a guest. Then merge the table and the list below it into one view, since both now show name, address and apps.
/forgesnow opens with a "Forges side by side" table above the existing client list. I ranbun x tsc --noEmit, design-lint and the tests for what I touched, and they pass. I did not run the whole check, and I did not boot the app or look at the page. The new tests don't render the table.Each forge gets one row with the same columns, so nothing shifts: name, address, size, memory, CPU and disk, included apps, and the last heartbeat received.
Where it's wired in:
src/apps/contain.ts. I added a CPU figure to it, averaged over the watch's window, and it stays empty until the window has half filled.src/spawn/usage.tsturns a child's reading into plain data and words.size,useandlastHeartbeatAtare now on each client row. TheforgesMCP tool and the terminal screen read those rows, so an agent gets the same fields.docs/features/forges-side-by-side.mddescribes it, andtest/forges-side-by-side.test.tscovers the reading and the not-found case.What I left out or chose:
/forges, as before. The ledger doesn't record a separate per-forge owner, so "owner" means this forge's admin. A non-admin still getsnot_found. If you want owners who aren't admins, the ledger would need to record who sent each forge.Closes #2933