Skip to content

fix: real machine specs, a volume meter, and one settings style system - #112

Merged
pythonlearner1025 merged 10 commits into
mainfrom
fix/my-machine-stats
Aug 29, 2026
Merged

fix: real machine specs, a volume meter, and one settings style system#112
pythonlearner1025 merged 10 commits into
mainfrom
fix/my-machine-stats

Conversation

@pythonlearner1025

Copy link
Copy Markdown
Member

Two annotation rounds in one branch, follow-up to #110.

My-machine specs (the "Unavailable" bug)

Root cause: GET /machine-types is the catalog an org may create NOW — deprecated types, unavailable locations, and credential-gated providers are filtered out — so a machine running a filtered type had no size source and every spec row fell back to "Unavailable" with no error. The panel now keeps the whole response (failures, providerStatuses) and either renders the specs or one line naming the real reason. Rejections route through caughtErrorMessage; five tests pin the exact serve-shape.

Persistent-volume meter

  • Migration 0045: machines.disk_used_percent (0-100, nullable) + disk_reported_at.
  • POST /workspaces/self/machine-stats, box-authenticated, with a new machine-stats fixture corpus and conformance tests on both sides.
  • Guest reporter blitz-machine-stats + s6 service (10-min loop, fail-open) — reaches the field with the next box image; until then the UI says "usage not reported yet".
  • MachineView.volumeUsedPercent; meter UI in My-machine and the Members rows: % bar (red at 90%), pending state, or "Not attached". The bare word "Attached" is gone.

Settings surface style system

One canon stylesheet (settings-surface.css, cfg- prefix, documented rules in its header): fixed dialog height (760px ceiling, body scrolls, tabs never resize the frame); sentence-case ink section titles at the agent-rules anchor size; muted descriptions; exactly one thin divider between adjacent sections and nowhere else; sentence-case micro-labels. Applied across the details dialog's three tabs, My machine, the account settings page and panels, and the create dialog; six scattered heading treatments deleted. CLAUDE.md names the sheet as canon.

Gates: typecheck 0; lint at baseline (102/0, 7 max-lines); CP 639, actor 124, webapp 364, all pass; webapp build clean.

🤖 Generated with Claude Code

pythonlearner1025 and others added 10 commits August 29, 2026 19:43
…ilable"

On canary a member's My-machine panel showed CPU, Memory, Disk and Price as
"Unavailable" and the machine-type select showed the raw id `cx33@hel1`. The
fetch was not failing and the catalog was not empty: `GET /machine-types` is
what an organization may create NOW, not what its machines run on.
`HetznerProvider.listMachineTypes` drops deprecated types, drops locations
that report no availability, and keeps only the ids in HETZNER_MACHINE_TYPES;
`core/app.ts` drops whole providers whose access is `credential-required`. A
live machine on a type that has since left the catalog is normal and
documented (hetzner-config.ts), and `machines.find` then finds nothing.

The dialog had no answer for that and no reason to give, because it kept only
`response.machineTypes` and discarded `failures` and `providerStatuses` — the
two fields of the same response that say why the catalog cannot describe the
machine. `CreateWorkspaceDialog` already reads all three.

The panel now keeps the whole response. When the catalog holds the machine's
type it prints the size exactly as before. When it does not, the four spec
rows give way to one line naming the real reason: a provider failure and its
message, a provider that needs an organization compute credential, or a type
the catalog no longer offers. The type id stays on the "Machine type" row,
because it is the one fact that is known.

Second defect on the same lines: the catch typed its argument `Error` without
checking, so a rejection that is not an Error set the banner to `undefined`
and rendered an empty `role="alert"` paragraph. It goes through the shared
`caughtErrorMessage` helper now.

Tests mount the dialog with the exact envelope the control plane serves
(machineTypes decorated with providerId and supportsVolumes, beside failures
and providerStatuses) and pin the size rows, each reason line, and the
non-Error rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J6fUBY1B27EzvDwbhfBf52
The volume row said "Attached", which told a member what they could already
see. What they could not see is whether the disk they keep everything on is
about to run out. Nothing in the control plane knew either: a provider reports
a volume's size and never its usage. Only the guest can measure it.

Guest → control plane (new cross-runtime contract `machine-stats`):

- `box/rootfs/usr/local/bin/blitz-machine-stats report` reads `origin` and
  `box-credential.json` out of the state directory exactly as `blitz-rules`
  does, measures `df --output=pcent` on that directory — the volume's mount
  point when there is one — and posts `{ diskUsedPercent }`. Every failure is
  expected offline behaviour: it says why on stderr and exits 0, so a disk
  figure can never take a box down. A df line that does not match `NN%` is not
  sent at all, because `Number('')` is 0 and 0% is the one wrong answer that
  looks like a right one.
- The s6 longrun `machine-stats` (dependency: `register`, like `watch`) loops
  it every 600 s. The loop lives in the service so the script stays one shot
  and stays testable. It reaches the field with the NEXT box image.
- `POST /workspaces/self/machine-stats` (`core/machine-stats.ts`) is
  box-authenticated like the other `/workspaces/self/*` routes and writes the
  caller's own machine row. It accepts an integer 0-100 and 400s everything
  else — a float, a numeric string, a null, a missing field — because a wrong
  figure overwrites the last true one and the column cannot say "this is a
  guess". Extra keys are tolerated so a newer guest still lands its percentage.
  No revision bump: a disk figure moving one point must not wake every poller.
- Fixtures `packages/schema/fixtures/machine-stats/` are the accept rule, with
  conformance tests on both sides — the control plane over the real route with
  a real box credential, the guest by running the real script against a local
  origin and checking what it posts.

Storage and wire:

- Migration 0045 adds `machines.disk_used_percent` (nullable, CHECK 0-100) and
  `machines.disk_reported_at`. Neither takes a default: null is the honest
  pending state for every guest that predates the reporter, and 0 would read
  as an empty disk.
- `MachineView.volumeUsedPercent: number | null` in `wire-machines.ts` and
  `packages/schema` together, pinned by `wire-drift.test.ts` with both a
  reported and an unreported machine. The projection answers null for a
  machine with no volume: such a machine measures its VM's root disk, which is
  not the durable thing the field names.

UI, in both places volume state shows:

- `src/VolumeMeter.tsx` renders the three states as three different facts:
  attached and measured → the bar with "62% full"; attached and unmeasured →
  the empty TRACK with "usage not reported yet"; no volume → "Not attached".
  The word "Attached" is gone. The fill turns red at 90%, because a meter that
  never warns is decoration.
- The My-machine panel and the details Members rows both use it. A member row
  that holds a machine now reports the disk it has instead of a disabled
  checkbox; a draft row with no machine keeps the checkbox, which is still a
  real choice there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J6fUBY1B27EzvDwbhfBf52
Six heading treatments across four stylesheets is why the settings
surfaces drifted apart. `src/settings-surface.css` is now the single
place the settings look is defined, under one `cfg-` prefix, with the
rules stated at the top of the file so the next change extends it
instead of inventing a seventh.

The two anchors are tokenized rather than retyped: `--cfg-title-*` holds
the colour, size and tracking the "Agent rules" heading carried in the
Settings tab, and `--cfg-desc-*` holds its paragraph's. Every other
value resolves to a token already in tokens.css.

Nothing wears the classes yet; the following commits move each surface
onto them and delete the one-off it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the system

Anchor 1: the frame is `height`, not `max-height`. It is sized to the
tallest tab — Settings, whose form, agent rules, facts and repositories
run past the 760px ceiling the dialog already had — so Members and
Credentials now leave space under their content instead of shrinking the
dialog around it. The body is the only thing that scrolls.

Anchor 4: `.workspace-details-list > div:first-child` loses its
border-top. The fact lists are divider-free `.cfg-meta` now, and the
only lines on the surface are the section boundaries the system draws.

All three tabs, plus the agent-rules picker they share with the create
dialog, move onto `cfg-`: section heads with an ink sentence-case title
and a muted description, sentence-case micro-labels in place of the
tracked-out NAME / DEFAULT MACHINE TYPE caps, one actions row per form,
and the footer's Delete as `.cfg-danger-action`.

Deleted with their last caller: the micro-caps `h2` rules, the
`.workspace-details-list` list, `.workspace-details-note`,
`.workspace-settings-form`, `.workspace-settings-toggle`,
`.workspace-repos h2`, `.workspace-credential-add`,
`.workspace-details-delete`, `.blueprint-agent-rules` and
`.blueprint-agent-rules-note`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three sections — Machine, Machine type, Lifecycle — with ink
sentence-case titles, the divider-free fact list, `.cfg-help` for the
notes that used to be `.workspace-details-note`, and `.cfg-actions` for
the lifecycle verbs. Destroy wears `.cfg-danger-action`.

The dialog has no tab row, so nothing about it resizes: it keeps its
content height and the 760px ceiling, and only the tabbed dialog is
pinned.

`.cfg-meta-term` joins the vocabulary for the one case a fact list must
re-case its value: a wire enum ("running", "admin") read by a person.
The list itself never re-cases, because an e-mail address is a value
too — which is what the old capitalize-everything rule got wrong the
moment the account profile list joined the system.

The my-machine test now finds the lifecycle buttons through
`.cfg-actions`, the class that styles them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The create-workspace dialog's section headings are the anchor the system
was tokenized from, so they are the last thing that should have its own
copy of the rule. `.blueprint-selection__heading` and `.blueprint-field`
are deleted; every caller — the create dialog, and the template and
recipe screens that share the markup — wears `.cfg-section-head`,
`.cfg-title`, `.cfg-desc` and `.cfg-field` instead.

The visible change is the micro-labels: NAME and DEFAULT MACHINE TYPE
were 10px/700 uppercase tracked out .14em, and are now a quiet
sentence-case line above their control. The cards keep their outlines —
a card is structure, not a divider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Profile facts become a divider-free `.cfg-meta` in a section, so
Appearance is separated from them by the system's line rather than by a
`border-top` of its own. Members and Invites get `.cfg-field` labels,
Members gets the shared `.cfg-danger` zone with a red `.cfg-danger-action`
Leave, Usage's notes become `.cfg-help`, and the two connections sections
get `.cfg-section-head` titles.

The all-caps micro-labels go with them: `.connect-field__label` on the
compute and provider forms is `.cfg-label` now, and the panel-header
eyebrow ("ACCOUNT SURFACE", "ORGANIZATION") is sentence case at the
description's colour and size.

Deleted: `.settings-definition-list`, `.settings-field`,
`.settings-danger`, `.settings-appearance` and its label and note,
`.settings-section-heading`'s typography, the credential-section
spacing, and `.connect-field__label`.

Two tests followed their markup: the provider-admin field labels and the
shell smoke test's Leave button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also two leftovers from the dialog conversion: the untabbed My machine
body keeps the 240px floor the frame used to give it, and the dead
`.workspace-repo-add` rule goes with the wrapper the repo section no
longer needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blitzdev emitter suite is vendor-gated (BLITZDEV_MANAGED=1), so the
local gates skipped it and CI caught it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pythonlearner1025
pythonlearner1025 merged commit bb2b73f into main Aug 29, 2026
pythonlearner1025 added a commit that referenced this pull request Aug 30, 2026
main gained the memory boundary (#113), machine-stats (#112), env-file
credential import (#114), the managed-count fix (#117), canary's R2 box
image (#115) and the connections/mobile fixes. This branch is the Lody
port, which deleted the box actor entirely.

How each side was chosen:

- `packages/box/actor/**` stays deleted. That was a direct user order and
  nothing here softens it. main's `actor/run` change is dropped with the
  service it wrapped.
- machine-stats is actor-independent guest work, so it stays: the s6
  service, `blitz-machine-stats` and the CP consumer land unchanged, and
  its conformance test moves to `packages/box/guest-tests/test/` the way
  the deletion commit re-homed every other guest test. The relative paths
  the test reads (`../../rootfs`, `../../../schema/fixtures`) resolve at
  the same depth, so nothing in it needed editing.
- The memory boundary's one actor dependency was the placement of the
  process that hosts agents. The Lody daemon is that process now, so it
  inherits the scope: `lody-daemon/run` enters `user/lody.scope`, and
  `docs/MEMORY-BOUNDARY.md`, `blitz-cgroup` and `smoke.sh` say so. The
  daemon is dark by default, so smoke.sh reads the run script rather than
  looking for a node that is not there.
- `.github/workflows/**` takes main's side whole — this branch never meant
  to touch canary.yml, and after the merge the directory is byte-identical
  to origin/main.
- `CreateRecipeScreen.tsx` takes main's `cfg-` settings-surface styling and
  drops the chat-harness copy and `chatNeedsModel`, which have no referent
  on this branch.
- `WorkspaceDetailsDialog.test.tsx` takes main's `IMPORT_PREVIEW_DEBOUNCE_MS`
  import beside this branch's `SessionRail` rename.
- `shell-smoke.test.tsx`: main's new mobile-drawer test passed an `acp` port
  that `StandalonePorts` no longer has.

Adding a file under `core/` touches three hand-maintained lists, and the
merge is where two branches' additions meet. All three take the union:
`worker-source.mjs` gains main's `workspace-credential-import.ts` beside
this branch's `workspace-drain.ts`; `core-imports.test.ts` counts 109
(104 at the fork, +2 from main, +3 here); and the managed upload set in
`blitzdev-emitter.test.ts` gains `core/wire-sharing.ts`,
`core/session-shares.ts` and `core/workspace-drain.ts` to reach 112.
Those three are 2.2 KB, 14 KB and 2.9 KB of source, so the emitter's
1 MiB-per-file platform limit still holds with four orders of magnitude
to spare — and the test asserts it on every file, not just the new ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vk3ghWvYvp74ae9EUQPPkJ
pythonlearner1025 added a commit that referenced this pull request Aug 30, 2026
§13.1 said "bake the box image" and then gave the command that bakes the
GOLDEN HETZNER SNAPSHOT. Those are two different images, and the merge is
where that stopped being a wording problem: #115 gave canary its own box
image, served as an R2 archive from canary's account, pinned by three
`BLITZ_DEPLOY_VAR_BOX_IMAGE_*` lines in canary.yml, and made
`docs/BOX-IMAGE.md` the procedure. Cutting a `v*` tag — the old reflex for
refreshing an image — now ships the platform to a paying client.

So §13.1 names both images, says which one every Lody change is in (the
box image, image 1), and says the golden snapshot is optional and reads
image 1's pin rather than replacing it. A stale snapshot is only slow: the
bootstrap fetches whatever `BOX_IMAGE_*` names, so it cannot ship an old
box.

§13.2 gains the machine-stats reading, because #112 landed a new guest →
control-plane route in the same image and the obvious question is whether
it needs a fourth capability cutoff. It does not, and the reason is the
rule: a cutoff is needed where the OLD side rejects the NEW payload. The
`share` claim has that problem — an older gateway refuses the ticket. A box
that never POSTs machine-stats just leaves `volumeUsedPercent` absent.

§13.4 gains the two checks the same recycle is the only chance to make:
the volume meter appearing within ten minutes, and the daemon sitting in
`blitz-user.slice/lody.scope` — the memory leaf it inherited from the
deleted actor. Step 1 also notes that `boxImageRef` now reads as an R2
manifest URL, so the tag and digest beside it are what to check.

Two line references in §13.2 moved with the merge and are corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vk3ghWvYvp74ae9EUQPPkJ
pythonlearner1025 added a commit that referenced this pull request Sep 1, 2026
The redesign branched at #110, three hours before #112 landed
`settings-surface.css` — the `cfg-` canon CLAUDE.md now names as the one
system for every settings-shaped screen. Both sides restyled the same
surfaces, so the resolution keeps the redesign's STRUCTURE and main's
VOCABULARY: primitives, the org switcher, the surface ladder and the
danger-zone/About headings survive, expressed in `cfg-` classes rather than
in a second set of headings, fields, dividers and fact lists.

Per file:

- settings.css — the redesign's ladder and its `.settings-switch-*` shape
  stay; every rule whose markup moved to the canon is gone (definition list,
  appearance wrapper, `.settings-field*`, `.settings-danger*`, `.settings-note`,
  `.settings-credential-section`, `.connect-field__label`). The account page
  keeps its red frame around the canon's `.cfg-danger`.
- workspace-details-dialog.css — the ladder's raised cards stay; the heading,
  field and fact-list rules the canon owns are gone, and #114's credential
  import and #112's volume meter come across whole. The settings form's
  machine-type select re-targets `.cfg-field` and takes the one control height.
- SettingsPage.tsx — main's `.cfg-meta` profile facts and `.cfg-section`
  appearance block, plus the redesign's Organizations section, re-headed
  with `.cfg-section-head` / `.cfg-title`.
- MembersPanel.tsx — the redesign's named "Danger zone" over main's
  `.cfg-danger` / `.cfg-danger-action` zone.
- UsagePanel.tsx — the redesign's `SettingsSwitch` (it writes on change, which
  is what the primitive is for) with main's `.cfg-help` notes.
- WorkspaceSettingsTab.tsx — main's `.cfg-field--inline` checkbox, NOT
  `SettingsSwitch`: that row is part of a draft the Save below sends, and the
  primitive's own contract forbids a Save beside it. `cfg-actions` and
  `cfg-meta` as main has them, with the redesign's "About" title.
- WorkspaceDetailsDialog.tsx — main's `.cfg-section` credentials tab and its
  comment field, with the redesign's red Revoke.
- CloudApp.tsx — the redesign cuts the desktop statusline; #155's BUG-CV-02
  sentence keeps one desktop surface, because a box the browser cannot reach
  has no other affordance. The bar now shows on desktop when the terminal
  needs its sign-in pair OR when the gateway is unreachable.

Also carried across the version gap: `settings-surface.css` gains the disabled
state the redesign gave its danger button, the preview fixtures gain the fields
main added to `MachineView`, `WorkspaceCredentialView` and `CatalogEntryView`,
the preview gallery passes `refreshWorkspaces`, and two tests drop props the
redesign removed.

Gates: typecheck and lint:gate pass at baseline (74 anti-slop, 0 house).
`npm test` passes except `webapp/test/lody-worktree-composer.test.tsx`, which
fails the same way on origin/main alone — a pre-existing failure, not this
merge's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhdxNAYjV97JTv2pddcJiN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant