feat: member machines — the workspace is a server, every member gets a machine - #106
Merged
Conversation
Supersedes plans/MULTI-MEMBER-BOX.md (PR #91). Every member of a workspace gets one always-on machine, sized by the workspace machine type, provisioned on invite, destroyed on leave. Machines never appear in the product. Includes the session-rail UI overhaul spec and the mockup it is grounded on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five parallel read-only sweeps over main @ cbf9a1f: compute lifecycle, identity and entitlements, box and sessions, webapp, credentials. File:line grounded; feeds the MEMBER-MACHINES builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remove the template concept; store workspace members with roles; move every VM column to a machines table keyed by (workspace, membership) or invite; auto_provision toggle; workspace-admin vs org-admin split; workspace_credentials added on top of always-available personal credentials; defer auto-upgrade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tion The workspace holds only a default. Each machine carries its own type, overridable at create and invite, mutable via SetMachineType: destroy the VM, keep the volume, provision the new type on the same volume. Cross-location change deferred until a volume move exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt columns 1 and 2 of the session-rail mockup now, listing the existing managed tab types per workspace. Tab strip, panes, right icon strip, and mobile drawer stay unchanged. Build 2 later swaps the rail's data source to session rows without a layout change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mockup is the canonical reference for strip and rail: structure, dimensions, and tokens come from it. Add an element-by-element mapping from mockup selectors to today's data and to the Build 2 swap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Workspace members are added directly from active org members, and the machine allocates immediately on add (auto_provision default on). The machines table keys by membership only; invite_workspaces is gone. Org invites stay an org concern, out of scope. Add a terminology note: machine (durable object) / vm (provider incarnation) / box (guest runtime). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete the boxes table. Phone-home enrolls against the machine row and mints machine_token_families stamped with the vm they were minted for. The acting principal derives from machines.membership_id at call time, so the D4 misattribution becomes structurally impossible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move workspace-credential management from org admin to workspace admin. Restate the role scopes with a per-role summary and a cleaner matrix (org admin passes through implicit reach). Add §6b: MachineTypeSelect and WorkspaceMembersEditor, reusing MachineCatalogGrid, the ShareWorkspaceDialog people-search pattern, and WebAppSelectMenu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rg-admin Roles are now admin | member | viewer. Add a naming note against the org memberships table. Workspace creation is org-admin only for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
env rides the existing creds/env.d path, sealed and revocable; header is on-demand via blitz-cred with the recorded header shape, optionally proxy-custodied so the value never reaches the machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workspace_credentials replaces workspaces.environment. A row is an env var name plus a sealed value: exported ambiently through env.d, and served on demand by blitz-cred behind the personal-first rule. The delivery enum and header shape are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete the env.d ambient delivery: the broker-written file, profile sourcing, tmux -e, chat-turn env merge, and the workspace-environment contract retire. blitz-cred is the single door; agents export scoped env vars themselves when a tool needs one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Workspace credentials serve through blitz-cred only (plans/MEMBER-MACHINES.md §1). The ambient env.d delivery path was the only consumer of the workspace-environment route, so the fetch, the decoder, the creds/env.d/00-workspace.sh writer and the profile hook that sourced it all go together. The startup script and filesReady rode the same route and the same workspaces.environment column, so they retire with it: the control-plane route becomes legacy-empty and the column is dropped. blitz-cred list/get/env/git-helper are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The broker no longer writes creds/env.d/00-workspace.sh, so the two readers go with it: /etc/profile.d/blitz-creds.sh is deleted, and blitz-term drops the glob and the environment delta it fed into `tmux -e`. The locale pair stays on `-e`, because the tmux server snapshot a box boots with has no LANG and that has nothing to do with credentials. Session naming, the read-only attach and recipe injection are untouched. The actor's CredentialSource keeps harness-token delivery and loses the workspace-environment merge: a turn now runs on the actor's own environment, and every credential is pulled at the moment of use with `blitz-cred get`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CloudApp.tsx was 2,265 lines and on the max-lines debt list. Four containers move out, mechanically and with no behaviour change: - src/shell/ShellRail.tsx — column one (wraps DriveRail today) - src/shell/WorkPanes.tsx — the .webapp-panes grid and its surfaces - src/shell/SecondaryRoutes.tsx — every non-workspace route branch - src/shell/ShellDialogs.tsx — the modal stack the rail raises - src/shell/PasteCodeModal.tsx — lifted out of CloudApp's preamble CloudApp.tsx: 2,265 -> 1,877 lines. Gates unchanged: typecheck clean, lint baseline untouched (102 anti-slop, 7 max-lines warnings), webapp 41 files / 336 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fixture corpus and its conformance tests pinned a payload no runtime consumes any more. The control-plane route file is left intact so it keeps compiling while it is made legacy-empty; only the corpus and the conformance test go here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rail rebuild would have broken ~20 assertions that reached for
presentational classes. They now ask for the same things by accessible
name:
- `.drive-rail` textContent -> button[aria-label="<workspace>"]
- `.webapp-org-button/-menu-*` -> aria-label + role=menuitemradio state
- `[data-rail-session-id]` -> [aria-label^="Sessions in "] button,
active read from aria-current
- `.webapp-pane-strip[data-region]` -> the strip's own tablist label
- `.webapp-rail-strip` -> [aria-label="Workspace panels"]
- `#webapp-workspace-drawer` -> [aria-label="Workspace drawer"]
- `.drive-rail--open` -> the toggle's aria-expanded
Three accessible names were missing and are added: the org button names
its org, the org menu names itself, and a workspace row names itself.
The org-menu backdrop becomes a labelled button, matching the scrims
elsewhere in the shell. `runningTwo` gets its own name so two rail rows
are not both called "workspace-running-name".
webapp 41 files / 336 tests green; typecheck clean; lint baseline
untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Columns one and two are rebuilt against plans/mockups/session-rail.html (plan §6a). DriveRail conflated org header, global nav, workspace list and sessions in one 264px column; it is gone. - `src/shell/WorkspaceStrip.tsx` (48px): the org mark carrying the org-switcher popover, one `.wtile` per workspace (2-3 letter code, name as the accessible name, state in the tooltip, ring on the active one, dashed when the box is not running), a "+" tile for the create dialog, the Files/teenyapps/Connections surface icons, and the account menu on the bottom edge. - `src/shell/WorkspaceSessionRail.tsx` (252px): `.rhead` with the workspace name, an empty `.sub` slot, and share; a pinned "New session" bar; one row per managed tab, drawn gutter · title · time with the time slot empty. A row click activates that tab. - `src/shell/ShellNav.tsx` composes both. Above the breakpoint it is `display: contents`, so each column is a real grid child; below it, the wrapper is the off-canvas drawer the rail used to be, and it carries the rail too whenever a workspace is open. - `src/strip-rail.css` ports the mockup's dimensions, radii and row sizes onto the tokens.css scale; `--live` is the one token it added. - The shell grid becomes 48px | 252px | work | icon strip. Drive, settings, templates and recipes keep the strip and take the width. Deviations from the mockup, deliberate: - The rhead also carries a details button. The mockup has no per-workspace kebab, and §6 has not landed, so this keeps the details dialog reachable. - The surfaces keep the names the right icon strip already uses (Files, teenyapps, Connections) rather than the mockup's "Ports": one panel, one name. - Templates and Recipes leave navigation with the template concept. Drive moves to the account menu, which also carries the Discord link. - The rail's New session offers the tab strip's list from the same SPAWN_SESSION_TYPES source; WebAppHeader is untouched. 654 lines of DriveRail-only CSS leave webapp-shell.css and drive-shell.css. webapp 41 files / 342 tests green; typecheck clean; lint baseline untouched; vite build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guest runner is gone and the control-plane column drops with it, so the editor would have become a setting that silently does nothing. Deleted: `src/EnvironmentEditor.tsx` and both its mounts (the create dialog's Advanced section and the template screen's), the `environment` field on both GitHub connect drafts and its parser, the `environmentConfigured` / `startupConfigured` derivation in `api-adapter.ts` with the two Configuration rows in the details dialog that read it, and the matching fields on `CloudWorkspaceModel` and `WorkspaceRecord`. Neither the create body nor the template body carries an `environment` any more. 43 lines of now-dead CSS go too. Credential UI is untouched — a later pass owns it. The wire types in `packages/schema` are untouched: another agent owns that side. webapp 41 files / 339 tests green (three environment-editor tests are deleted with the surface); typecheck clean; lint baseline untouched; vite build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…achine
Implements plans/MEMBER-MACHINES.md §1-§4 in the control plane and schema.
Migrations 0041-0044 (0040 is reserved by the unmerged trials PR):
0041 workspace_members, machines, machine_token_families; the data
migration reuses each existing box id as its machine id, so every
deployed guest keeps the identity and the token family it already
holds; workspace_grants and org_share_role convert to stored roles.
0042 workspace_credentials, sealed AES-256-GCM under
`wscred:<workspaceId>:<name>`; workspaces.environment converts.
0043 drops the four workspace_template tables; recipes re-point at a
source workspace and their launch is parked behind a 400.
0044 drops workspace_grants and every VM column the workspace shed.
Core: performWorkspaceCreate writes a config row and provisions one machine
per member; machine lifecycle (provision/stop/start/recreate/destroy/
SetMachineType); workspace member and credential routes; phone-home and
box-config become per machine; the webApp proxy routes to the requesting
member's machine; the credential mint resolves its acting principal from
machines.membership_id at call time, which retires the stored-principal
path; janitors, vm_limit and vmsUsed all count machines.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test helpers at machines Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty backfill Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ole sharing model Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s parked Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Templates and Recipes are disabled product-wide (2026-08-29). Both registrations stay in core/app.ts, commented, with the reason; recipe code and rows are untouched and only the routes are gone. Org usage capture is split out of core/recipes.ts because it is not a recipe surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the owner's guest read-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…known input The parameter really is unparsed provider output, so it stays `unknown` and the anti-slop baseline stays where it was rather than being widened by a type that only looks narrower. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guest side retires the workspace-environment contract; the core side keeps the constant shim the deployed brokers still poll. CLAUDE.md: the contract row is removed (guest's deletion wins over core's "(RETIRING)" annotation), guest's retirement note stays, and the Member machines section gains the line that names the surviving shim and the one test that pins it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main's PR #103 hides the Recipes rail entry inside DriveRail.tsx. This branch deletes DriveRail for the strip and rail, so the deletion wins: the new strip carries no Recipes entry to hide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mm-core left `orgId`, `ownerMembershipId`, `defaultMachineTypeId`, `autoProvision`, `myRole`, `members` and `credentials` optional because the webapp fixtures lived on another branch. They are in the same tree now, and the only client of this view is that webapp, so optionality bought nothing and cost every reader an absence to handle that cannot happen. The webapp test fixtures move onto one `workspaceViewFixture` builder rather than restate seven defaults in five files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… wire The control plane serves members, machines and workspace credentials, so the webapp stops calling the retired `workspace_grants` routes and administers the real thing. New per plan §6b: - `MachineTypeSelect` — the compact type picker on `WebAppSelectMenu`, sharing `groupMachineTypes` and `monthlyPriceLabel` with the grid. On a live machine it disables the types outside the volume's location and says which location that is, because a missing option reads as a bug. - `WorkspaceMembersEditor` — one row, two modes. Draft feeds `CreateWorkspaceRequest.members[]`; live calls the API per edit and carries the machine state chip and the lifecycle menu. The details dialog becomes Members / Credentials / Settings. Compute and Storage collapse into the member rows, because a workspace has no single machine to describe. `SetMachineType` confirms that it keeps the disk before it writes. Settings is read-only over the fields no route can write yet, and says so rather than offering controls that do nothing. `ShareWorkspaceDialog` retires with `workspace_grants`: membership is sharing now, so the rail's people icon opens the Members tab. `ownerMembershipId` comes off the wire instead of being inferred from "the viewer is the owner", which could only ever name the viewer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sections The control-plane registrations are already unmounted, so these addresses opened pages whose every request 404s. The route branches are commented out in sessions-page-state.ts with the reason, /templates* and /recipes* fall through to Drive, and SecondaryRoutes stops rendering them. The page components, the client methods and the recipe rows stay in the tree, unreachable. The create dialog loses the Templates section with them — a workspace is its own template now, and "new workspace from existing" is the clone. New coverage: the default-machine-type label, a per-member type override, a viewer row without one, credential rows, the org-admin refusal, and the clone header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`PUT /workspaces/:id/org-role` is gone from the control plane, and the `orgShareRole` it wrote is projected as a constant null: sharing is a stored workspace role now. The client method could only 404, and the model field could only read null while looking like a sharing signal. `CreateWorkspaceRequest.orgShareRole` stays: at create it is still a real instruction, adding every active org member at the matching workspace role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lifecycle menu listed Provision on a member with no machine, but the projection sends `machine: null` for a destroyed or absent one and every lifecycle route is keyed by machine id — so that entry could only ever report an error. A member gets a machine through the role write, which provisions one where the workspace auto-provisions. Provision now appears on an error row alone: the one reachable state whose VM may be missing. A machine that is provisioning or destroying accepts nothing until it arrives. The state chip renders on a machineless row too, so the absence is stated rather than left blank. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The details dialog rendered name, default machine type, auto-provision, agent rules and repos, then explained that no route existed to change any of them. The route exists now. `PATCH /workspaces/:id` takes a partial body and leaves every absent field alone, so the four controls do not have to restate each other. It is workspace-admin work per the §3 matrix, and an org admin passes through implicit reach — the same `workspace-access.ts` gate the member routes use, which moves here as `workspaceForAdminWrite` rather than being copied. `defaultMachineTypeId` is validated against the machine-type registry at write time, so a workspace cannot hold a type no provider claims and discover it at every provision instead. It is a default: it moves what a FUTURE machine takes and touches no existing one. The repository list was fixed at create (plans/WORKSPACE-REPOS.md, "add and remove come later"). Three routes serve it now: a read any member of the workspace may make, and an admin-gated add and remove. The add derives `private` with the caller's own credential and refuses a private repo without an App grant, exactly as create does — a clone that cannot authenticate takes 600 seconds to say so, so the refusal belongs at save time. A change reaches a machine at its next provision. Routes live in a new `core/workspace-settings.ts` rather than in `core/workspaces.ts`, which is over the 700-line warn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A member row could exist with no machine — the workspace has `auto_provision` off, or theirs was destroyed — and nothing in the product created one. Every lifecycle route is keyed by a machine id, and that row has none, so the Members tab offered the member no verb at all. `POST /workspaces/:id/members/:membershipId/machine` is keyed by the membership instead. Workspace-admin gated per §3, with org admins passing through implicit reach. A viewer is refused: they never hold a machine (§2.2), and the role write is the way to give them one. A member who already has a live machine is refused too — this route creates, and `start` and `recreate` are the verbs for one that exists. An existing destroyed row is reused, so the member comes back on their own disk. The optional `machineTypeId` overrides the workspace default for this one machine, and an unclaimed type is refused before anything is created. `machineActionsFor(null)` now answers `['provision']` rather than an empty list, so the row offers the one verb that applies to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five deletions that leave no consumer behind, plus the two query narrowings and the recreate fix that fell out of reading them. - `orgShareRole` goes end to end: the create dialog's Sharing section, the parse branch, the roster expansion, and the field on both `CreateWorkspaceRequest` and `WorkspaceView`. The members editor is the only membership control at create, so the per-member quota swallow goes with the roster it existed for — a create that spends the org's last VM slot now answers the 409 instead of handing back a short roster. - `CreateWorkspaceRequest.environment` goes with its parse, its convert-to-credentials loop, and the now-unreferenced parser and ceilings in `core/environment.ts`. The constant three-field shim route stays untouched: deployed brokers still poll it every second at boot. - `WorkspaceView.orgShareRole` and `.environment` were constant null. The phase and ssh projections are NOT these and are left alone. - `GET /workspaces/:id/credentials` had no consumer: the names already reach the webapp on the workspace view. The route, the client method, the response type and the five test doubles go together. - Dead imports in `connections/mint.ts` and the unused `MachineView` in `wire.ts`. The rest of that import block binds names the declarations below use by hand, so it stays. Two narrowings and one fix found while reading the above: - Names-only credential queries stopped selecting `ciphertext`. `WorkspaceCredentialRow` is now the columns the view reads, and the one caller that wants a value selects `ciphertext` alone. - Recreate no longer falls back to `machines[0]`. With auto_provision off that restored the workspace on ANOTHER member's volume; the owner's machine or the existing 409. - Migration 0042 drops two json_* clauses the JOIN already implies, keeps the GLOB, and says plainly that underscore-prefixed names are discarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s a poll `createWorkspaceModel`, `workspaces_loaded` and `workspace_records_refreshed` each listed the record's fields by hand. The two merge paths had drifted: neither carried `agentRuleId`, so the Settings tab compared the value it loaded once against a rule the user had since changed, and read stale. `applyRecord(existing, record)` is the single projection all three now use. What it takes from `existing` is only what the server does not own — the local title, the chosen agent, and the last value of the three fields a record may omit. Everything else comes from the record, so the next field added to `WorkspaceRecord` reaches all three paths at once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Templates leave the feature list (a workspace is its own template), recipes move to the roadmap, and the sharing story becomes: add a teammate, their machine is running before their first click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pythonlearner1025
added a commit
that referenced
this pull request
Aug 29, 2026
* fix(webapp): A2 — one Drive icon in the strip, not three panel toggles The strip's Files / teenyapps / Connections buttons duplicated the right icon strip (WorkspaceRailStrip), which owns those panels. The slot now holds a single Drive icon that navigates to the Drive page — the route the account menu's Drive entry used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): A3 — the rail's "New tab" reuses the tab strip's menu The rail's pinned action drew its own popover that looked like the tab strip's "+" menu but listed less: no live ports, no preview links. The menu now lives in one component, src/NewTabMenu.tsx, and both call sites render it with their own anchor. SessionTypeIcon moves to src/SessionTypeIcon.tsx so the shared menu needs no import back into WebAppHeader; WebAppHeader re-exports both, so its importers are unchanged. The pinned action reads "New tab". The "+" button behaves as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): A4 — Refresh moves under the repositories list, right-aligned PR #95 put Refresh in the filter row. It now sits in a footer row under the list, on the right edge, beside the selection count. Behaviour is unchanged: the same click still bumps the refresh version. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): A6 — a deterministic gradient per workspace tile The strip's tiles were flat, so a two-letter code was the only thing telling them apart. src/shell/workspace-tile.ts hashes the workspace id to a hue, paints a 135° gradient to hue + 40°, and picks a near-white or near-black ink from the gradient's average luminance. Ids whose average lands in the band where neither ink clears 4.5:1 are darkened out of it; measured worst case over all 360 hues is 4.83:1. The active ring and the dashed create tile are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): A7 — the avatar opens settings, and the menu is gone The strip's avatar drew a four-item popover. Drive is a strip icon since A2, Settings was one click away, and the name was a label. Clicking the avatar now navigates to settings. "Ask us on Discord" moves to the bottom of the settings side navigation, under the section tabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): B1 — the details dialog wears the pre-#106 chrome again The dialog #106 built kept the three tabs the plan asks for and lost the look the pre-#106 one had. This puts the look back without touching what any control does: - the header says `Workspace details “name”` again; - every panel opens with the micro-caps section heading the old `workspace-details-grid` sections wore; - the member rows take the old access-list geometry (a 28px avatar, a 48px row) instead of the taller #106 one; - Clone and Delete leave the bottom of the Settings tab for the restored `workspace-details-footer`, which is where the old dialog kept Delete. Members, Credentials and Settings are the same three tabs, and every write behind them is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: a member row can refuse the persistent volume Every member machine has taken its own volume since #106, and there was no way to say a machine does not need one. The member row now carries the answer. Wire: `members[].persistentVolume` on the create request and the same optional boolean on `AddWorkspaceMemberRequest` and `ProvisionMemberMachineRequest`, in `wire.ts` and `packages/schema` together. Absent means true, so only a refusal travels. Control plane: the flag reaches `ProvisionMachineInput` and, at the ONE existing call site in `provisionMachine`, skips `provisionWorkspaceVolume` exactly as an explicit `volumeId` already does. No new volume path was written. Webapp: the toggle sits next to the machine-type select in every member row, in the create dialog's draft rows and in the live members editor. A row whose machine exists reports the disk it has, disabled — the disk is created with the machine and this is not the route that moves one. The workspace-level Volume section leaves the create dialog; `volumeId` stays on the wire, where a recreate still reads it. The test fake grew an optional `volumeLocation`, so a suite can turn on the volume placement the fake otherwise declines to do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): B3 — the select popover takes the top stacking position Inside the workspace-details dialog the role listbox, the machine-type listbox and the lifecycle menu all came out under their neighbours. The popover was `position: absolute`, so `.workspace-details-body` — which scrolls — was both the thing that clipped it and the thing it could not paint over. One rule in the shared stylesheet fixes all three: the popover is `position: fixed` with the top stacking position, so no scroll container between it and the page can clip it. `WebAppSelectMenu` writes `left` and one of `top`/`bottom` from the trigger's viewport rect, clamped to the viewport, flipping to whichever side has more room; it follows a scroll in any container above it. The one per-instance override of the popover's geometry (the mobile settings picker) is gone with it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webapp): B4 — a right-click menu on a workspace tile The strip's tiles answered a left click and nothing else. A right click now opens the same context menu the tab strip serves: the chrome is `.webapp-session-menu`, positioned at the pointer and clamped to the viewport, closed by Escape or by a click outside. Three items, graded by §3: - Rename, which edits the name in a field the popover holds — the tile is two letters wide — and writes it with the PATCH the settings tab already uses; - Settings, which opens the details dialog on its Settings tab; - Invite, which opens it on Members with the add-member picker focused. There is no email invite here: adding somebody means adding an existing member of the organization. A member sees Settings alone. The left click is untouched, and a right click never selects the tile it opened on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(webapp): B5 — "My machine" behind the rail's box icon The rail header said what the workspace is and never what the member's own machine is. A third button opens that panel, wearing the box glyph `files/DriveIcons.tsx` has drawn since before #106. The dialog is the workspace-details chrome with no tab row: one view, because there is one machine to read. It names the state, the type and what the type is (vCPU, RAM, disk, price, all off the existing catalog helpers), whether a persistent volume is attached, and the machine's own error where it has one. The lifecycle controls carry the §3 matrix in the first person. Stop and start are a member's own business; recreate, destroy and the machine-type change are workspace-admin work, and provisioning where there is no machine row at all is the member-add route in disguise. A verb this member may not run is shown DISABLED and names who to ask — "Ask a workspace admin: Ada Owner" — because there is no request to file and pretending one was filed would be worse than saying who can act. A viewer holds no machine (§2.2) and is told so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(webapp): B6 — the rail's Members button wears the Drive share icon The rail drew its own three-node share glyph while the Drive page drew a person-with-a-plus for the same verb. The rail now imports the Drive page's `ShareGlyph`, so one shape means "share" across the app, and the strip's copy is deleted rather than left to drift — the rail was its only caller. The button still opens Members and nothing else changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
plans/MEMBER-MACHINES.md(the plan and its ground-truth survey ride in this PR). Built by parallel agents on three branches, integrated and gap-closed; all gates green at every merge point.The model
A workspace is configuration; a
machinesrow is the VM. One machine per (workspace, member), provisioned when a workspace admin adds an org member, destroyed when they leave. Stored rolesadmin | member | viewerinworkspace_members. Machine types are per machine — the workspace holds only a default — andSetMachineTypeswaps the VM under the volume. Workspace credentials are one sealed store served throughblitz-credonly. The acting principal resolves at call time frommachines.membership_id; the stored-principal misattribution is structurally gone.Migrations (0041–0044)
workspace_members,machines,machine_token_families. Data-preserving: machines reuse box ids, token families copy hash-for-hash,broker_keysre-keys 1:1 — no deployed guest re-enrolls. Grants andorg_share_roleconvert to stored roles.workspace_credentials(sealed, AAD-bound); existing env vars migrate under aplaintext:v0:envelope that any write re-seals.recipes.template_id→ nullablesource_workspace_id.workspace_grants,org_share_role,environment.Compatibility (load-bearing, no expiry)
GET /boxes/:id/feedserves from machines;GET /workspaces/:id/environmentis a constant{env:{}, startupScript:null, filesReady:true}because deployed brokers poll it at boot; phone-home legacy adapter untouched.Removals, called out
blitz-credis the single door.core/app.ts; recipe code and rows untouched; webapp routes for both are unreachable.ShareWorkspaceDialogandPUT /workspaces/:id/org-roleretired in favor of the Members tab.UI
Three-column shell grounded in
plans/mockups/session-rail.html: workspace strip, session rail (lists today's managed tabs; sessions-as-objects come later), untouched tab strip and panes. CloudApp split 2,265→1,879 lines. Details dialog is now Members / Credentials / Settings with full machine lifecycle per the §3 matrix;MachineTypeSelect+WorkspaceMembersEditorin create and edit.Known follow-ups
Recipe launch re-point (
TODO(member-machines)incore/recipes.ts), rail.subslot feed, CloudApp split continuation, sessions-as-objects (plan Build 2).Gates
typecheck 0 errors; lint at baseline (102/0, 7 max-lines, never raised); CP 633 passed, actor 119, webapp 344, node:test all pass;
go vet+go testclean on broker and gateway.🤖 Generated with Claude Code
Appendix — the plan (plans/MEMBER-MACHINES.md, as merged)
MEMBER MACHINES — the workspace is a server, and every member gets a machine
Written 2026-08-28, revised same day after the ground-truth survey
(
plans/evidence/member-machines-ground-truth.md, main @ cbf9a1f).Supersedes
plans/MULTI-MEMBER-BOX.mdand the workspace-templates concept.UI mockup:
plans/mockups/session-rail.html.0. The idea
A workspace works like a Discord server. A workspace admin adds org members
to it with a role — only existing org members; the org roster grows through
the org's own invite system, which this plan does not touch. Each member
gets one always-on machine, provisioned the moment they are added, destroyed
when they leave.
Sessions inherit the workspace's agent rules, drive, and credentials.
Machines never appear as a user decision — only in workspace administration.
The workspace is its own template. There is no separate template object.
A workspace carries the config a template used to carry — machine type,
agent rules, repo list, workspace credentials — and "new
workspace from existing" clones that config. The
workspace_templates,workspace_template_folders,workspace_template_repos, andworkspace_template_connectionstables are deleted; recipes re-point theirlaunch source at a workspace.
1. The data model
workspaces — the durable, configurable thing
The workspace row loses every VM column (they move to
machines) and gainsthe config role the template used to play. A workspace has no
phase: it isalways present; only machines have lifecycle.
workspace_members — membership with a stored role
Replaces the computed role and the
workspace_grantssharing ACL. Roles arestored, not derived.
adminhere is workspace admin — distinct from orgadmin, with distinct powers (§3).
Org admins keep implicit reach into every workspace of the org (today's
invariant,
workspace-access.ts:39), but implicit reach is access, notworkspace-admin powers — see the matrix in §3.
machines — one VM per (workspace, member)
The machines table takes every VM column the workspace row loses. A machine
belongs to a workspace and to a membership — always. Workspace members are
org members, so the membership row exists before the machine does.
Terminology. A machine is the durable per-member object this table
defines (the word already exists user-facing as "machine type"). A vm
is the provider-level incarnation (
vm_id,createVm,VmProvider,vm_limit). A box stays a word for the guest runtime — the packages,the image, the gateway — but the
boxestable is deleted: the machinerow is the guest's identity. There is one row, not two.
Enrollment folds into the machine. Today phone-home inserts a
boxesrow plus a token family, and
boxes.principal_idstores the workspaceowner — the stored principal that causes the D4 misattribution. Unified:
machines.phone_home_hash, mints a token family inmachine_token_families(the renamedbox_token_families, keyed bymachine_idand stamped with thevm_idit was minted for), and flipsthe machine to
running. The capability re-arms at every vm provision.boxCallerresolvesthe acting principal from
machines.membership_idat call time — nostored principal exists to go stale. D4 stops being a fix and becomes
structure.
credential_leases.box_idbecomesmachine_id;GET /boxes/:id/feedbecomes
GET /machines/:id/feed.SetMachineType, recreate) revokes the machine'stoken families; the stamped
vm_idfences any stale guest thatoutlives its incarnation. The next boot enrolls fresh against the same
machine row.
(
broker_boxesis the broker's own fleet table and is unrelated; itstays.)
The volume is the durable machine; the VM is an incarnation. Machine
state is volume-backed (#88), so the VM fields are replaceable while the
machine row and its volume persist. This is what makes machine types
per-member and mutable (§1a): a type change destroys the VM, keeps the
volume, and creates a new VM of the new type.
The change sites for the old stored-principal path are known
(
workspaces.ts:1252-1256,mint.ts:243); both now read the machine rowinstead.
§1a Machine types are per machine, not per workspace
The workspace holds only a default. The model must never restrict which
types a workspace can hold. Three consequences:
member at create or add), else the workspace default.
SetMachineTypedestroys the VM, keeps thevolume, and provisions a new VM of the new type on the same volume. The
member's disk state survives; running sessions restart.
time.
One provider constraint carries over: a volume attaches within its own
location. A type change keeps the volume when the new type is in the same
location. A cross-location change needs a volume move — deferred (§5).
Automatic resize on pressure is also deferred;
SetMachineTypeis themanual path until then.
workspace_credentials — the statics the workspace adds
Sealed static keys, scoped to the workspace, managed by the workspace
admin in workspace settings (and at create time). Values are AES-256-GCM
sealed with the existing
CRED_MASTER_KEY, AAD =wscred:<workspaceId>:<name>. Note:this deliberately reverses migration 0028's ruling ("ad-hoc secrets are a
workspace file"); the reversal is intended and recorded here.
One store, one read — this table replaces
workspaces.environment.Today's two half-systems (plaintext workspace env vars; sealed but
org-scoped connections) unify here. A row is a name and a sealed value,
and the only consumer is
blitz-cred:blitz-cred get <name>/blitz-cred env <name>serve the value ondemand, behind the §4 resolution rule (personal grant first).
sets it itself, scoped as the agent rules already teach:
STRIPE_API_KEY=$(blitz-cred get STRIPE_API_KEY) cmd.An add is available at once — the next
blitz-credcall reads the storelive; no sync, no restart. A revoke refuses the next call. No value ever
sits in a file on the machine.
The
env.ddelivery path is deleted with this. It existed only forworkspaces.environment: the broker-writtencreds/env.d/00-workspace.sh,its profile sourcing, the
tmux -epass inblitz-term, the env mergeinto chat turns, and the
workspace-environmentcross-runtime contractwith its fixtures all retire.
blitz-credbecomes the single door toevery secret on the box.
Wire types
2. Lifecycle rules
or later): the
workspace_membersrow is written immediately. Ifauto_provision = 1(the default), a machine row is created in the sameact and provisions to
running. Ifauto_provision = 0, no machineyet; it provisions on first open or by workspace-admin action.
run them.
scope are destroyed after a grace snapshot; volume retention (existing
7-day sweep) covers restore.
after the last machine is gone.
machinesrows in live states;vmsUsedand theentitlements fixtures move to the same definition.
provision the new type on the same volume. Sessions restart; disk state
survives. Refuse a cross-location type until the volume move lands (§5).
existing org invite system. A new org member holds no machines until a
workspace admin adds them to a workspace.
3. Permissions
Three workspace roles, one implicit reach. In one line each:
settings, workspace credentials. The creator is the first admin.
drive write.
machine, no sessions of their own, no credential use.
workspace-admin reach in every workspace of the org (today's invariant),
plus the org-only concerns: the org roster and invites, billing, the org
compute credential, and workspace creation.
Org admins pass every ✓ in the WS-admin column through implicit reach.
Workspace creation is org-admin only for now; a later revision can open it.
Naming note: the workspace role
memberand the orgmembershipstableare different things. A person is an org member through
memberships, andholds a workspace role (
admin | member | viewer) per workspace.4. Credentials — two planes, one resolution rule
Personal plane. All of a member's personal credentials are available on
that member's machine, in every workspace: connection grants (GitHub, model
keys) resolve against the machine's member, and harness logins roam via
the broker exactly as today (
broker_membersis already per-principal).This replaces today's owner-resolution (
mint.ts:243) — the machine'smember IS the identity, so nothing is borrowed and no disclosure banner is
needed.
Workspace plane. The workspace box only adds the workspace
credentials from §1. Every member's machine in the workspace can use them
(viewer excepted — no machine). Audit rows name the machine and its member.
Resolution rule for
blitz-cred get <name>:<name>, if one exists — personal wins, sothe agent acts as the member whenever it can.
<name>.Injection-at-use (values out of transcripts) rides the existing seams
(
canUseTool.updatedInput, the git-helper pattern) and lands with Build 4.5. Deferred
later optimization. Until then,
SetMachineTypeis the manual path.(snapshot + restore in the new location). Until then,
SetMachineTyperefuses a type whose location differs from the volume's.
6. The workspace details page (revamp target)
The dialog at
/workspaces/:id("Workspace details", annotated for revamp)becomes the workspace administration surface. Tabs:
state chip, machine type selector (SetMachineType, with a "keeps the
disk" note), and lifecycle controls (provision / stop / start / recreate
/ destroy) per the matrix. An add-member control at the top — a picker
over active org members — with a per-member type override.
created-at; add/rotate/revoke for workspace admins. Values are
write-only.
auto_provision toggle, agent rules, repos, clone action,
delete.
Today's Compute/Storage panels collapse into the per-member machine rows;
per-machine detail (vCPU, RAM, disk, volume) opens from the row.
6a. Sidecar — the strip and the rail, before sessions
A UI refactor that ships independently of every build. The canonical
reference is the mockup:
plans/mockups/session-rail.html, live athttps://blitzos-session-rail.app.blitz.dev/. Adopt its
#stripand#railas designed — structure, dimensions, and visual vocabulary comefrom the mockup, not from this document. Its stylesheet is the visual
spec (
--paper/--ink/--accenttokens, radii, the 48px/252px columns);port those values into
tokens.cssrather than restyle by taste.Scope. Columns 1 and 2 only. No change to the right icon strip
(
WorkspaceRailStrip), the tab strip (WebAppHeader), the terminal andeditor panes, or the mobile drawer semantics. No sessions yet — the
rail lists the managed tab types that already exist under a workspace
(
claude | codex | terminal | chatfromwebapp_state), one workspaceat a time. Build 2 later swaps the rail's data source to session rows;
the mockup already draws the end state, so no second layout change comes.
Element-by-element mapping — mockup element → what feeds it now →
what changes at Build 2:
.appgrid48px 252px 1frdrive-shell's264pxrail column; the right icon strip stays as a fourth column#strip.orgmark#strip.wtileper workspacewtile--onring on the active one.beatlive dot when a session is live#strip"+" tile#stripsurface icons (Files, Ports, Connections)#strip.avavatar#rail.rheadname +.sub+ share icon.subslot stays empty (the mockup shows RAM; machines are not user-facing — open mapping, see below); share opensShareWorkspaceDialog.sub= live-session count or member count; share becomes the session share popover (Build 3).newbar"New session".srow:.ggutter ·.s__ttitle ·.s__atime — "never more".s__astays empty (tabs have no clock).s__a= time, green when live.asleeppanecreating/errorstates keep their existing main-pane copyA row click activates that tab in the tab strip; a strip click switches
the workspace. Non-workspace pages (Drive, settings) keep the strip and
use the remaining width. The Templates and Recipes nav rows die with the
template concept (Recipes is already hidden, #103).
Open mapping to confirm: the mockup's
.rhead .subshows theworkspace RAM. Per-member machines have no single RAM figure, and D3
hides machines. The slot stays; the feed is a product call.
Order of work (from the ground-truth survey):
feat/operator-consolefirst — it collides withDriveRail,CloudApp, andsessions-page-state.CloudApp.tsxinto rail container, work-pane container, routeswitch, and dialog stack. This is the first commit, before visuals.
DriveRail; rewire the 8railForcall sites.6b. Member and machine-type configuration UI
Per-member machine types (§1a) need UI in two places: workspace create and
workspace edit. The design reuses what exists; two components are new.
Implementation ships with Build 1.
Existing parts to reuse (from the ground-truth survey):
MachineCatalogGrid— the radio-card type grid, withgroupMachineTypes(provider + location groups) and
monthlyPriceLabel. Stays the pickerfor the workspace default.
ShareWorkspaceDialog— its people search over org members, suggestionlist, and per-person role selects are exactly the member-picker pattern.
The pattern is lifted; the dialog itself retires with
workspace_grants.WebAppSelectMenu— the listbox popover (outside-click, Escape, focusreturn). Base for the compact type select.
DriveAvatar— member avatars in rows.New component 1 —
MachineTypeSelect. A compact select onWebAppSelectMenu. One option per machine type: name, vCPU/RAM, monthlyprice, grouped by provider + location like the grid. First option:
"Workspace default ()". Two contexts gate its option list: at add
time, all locations; on a live machine (
SetMachineType), only types inthe volume's location, others visible but disabled with a "volume is in
" note (§1a constraint).
New component 2 —
WorkspaceMembersEditor. One list, two modes.Row = avatar · name · role select (
admin | member | viewer) ·MachineTypeSelect· remove. A viewer row hides the type select (nomachine, §2). Header = the lifted people search over active org members,
plus Add.
CreateWorkspaceDialog): edits local state only;submit sends
CreateWorkspaceRequest.members[]. The creator appearspinned as the first workspace admin.
once. Rows gain the machine state chip and the lifecycle menu from §6;
the type select performs
SetMachineTypewith a "keeps the disk"confirmation.
CreateWorkspaceDialoggains a Members section holding the draft-modeeditor, below the machine-type grid (which now labels itself "Default
machine type"). The details page embeds live mode. Nothing else changes.
7. Builds (revised)
Build 1 — workspaces, members, machines. The schema in §1 minus
credentials; migration off the template tables; provision-on-add with the
auto_provision toggle; destroy-on-leave; janitors re-pointed at
machines;vm_limit/vmsUsedre-based; the details-page Members tab. Done when: anadded member's first open lands on a running machine, a removed member's
machine destroys with a grace snapshot, and a WS admin can stop and start
any member machine from the page.
Build 2 — sessions as objects. Control-plane
agent_sessions; promotethe ACP actor. The rail shell already exists via the sidecar (§6a); this
build swaps its data source and adds clock, live state, owner, visibility.
Grounding: the survey's Build-2 section.
Build 3 — sharing. Unchanged: visibility enforcement, watch, send with
attribution, fork.
Build 4 — credentials. Member-resolution mint (D4 fix),
workspace_credentials+ the Credentials tab, resolution rule §4,injection-at-use, use-audit events.
Build 5 — pricing alignment. Unchanged, plus the fixtures move for
vmsUsed.Build 1 and Build 2 stay independent. Build 4's D4 fix can ship inside
Build 1 (it is one resolution change once boxes are member-keyed).
Appendix — standing decisions
personal-credential action.
Co-authored-byon commits; Fork is the identity escape.are the only exception. feat: operator console with sponsored trial invites #104 must land before Build 5.
the prior revision and the ground-truth survey.