Add durable workspace management - #130
Conversation
Review follow-ups on the Workspace management work. Correctness: - An unreachable member no longer takes down the collection. A folder that was deleted, unmounted, or taken over by another Workspace reads as "not a member right now" instead of throwing, so one unplugged drive cannot break `GET /api/workspaces` — which is the endpoint needed to unregister it. A malformed manifest or registry still throws: that is damage to surface. - A path whose folder was deleted and recreated is re-adopted under the identity now on disk. Selecting such a Home folder previously failed with an identity-mismatch error and stayed unrecoverable for the process lifetime, breaking the legacy selection flow it was meant to preserve. Two *live* paths claiming one identity are still refused as a copy. - The operation-lease guard runs before adoption, so a refused workspace switch no longer leaves the target carrying a manifest and a registration it never received. - The isolated preparation child adopts the manifest but no longer registers membership, leaving the Server process as the registry's only writer. - Managed deployments expose only their own Workspace. Other registrations in the same data directory are unaddressable there, so listing them leaked host folder names through the API that redacts host paths. Structure: - The registry is the single in-process representation of membership: re-read on access, with display metadata read back from each Workspace's manifest on demand. This replaces four overlapping maps whose reconciliation is where the bugs above lived. - Workspace identity is a precondition of storage, not a product of it, so the composition root resolves the repository on its own axis (`getWorkspaceRepository()`) instead of hanging it off `StructuredStore`. Managed mode adopted its Workspace through the on-demand storage path, which that path refuses for any backend with connections to open; the `initStorage` reuse that worked around it is reverted. - Drop what no longer has a caller: `getWorkspaceName`, the exported `commitWorkspaceHandle`, the lease's duplicate id axis, and the unused web route builders for an API with no client yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR
|
Self-review of this branch, and the follow-up fixes now pushed in 8cf6258. Everything in the original plan was implemented; what follows is what the review turned up on top of it. Bugs fixedOne unreachable Workspace took down the whole collection. A deleted-and-recreated path became permanently un-openable. Selecting such a Home folder failed with The operation-lease guard ran after irreversible side effects. Two processes wrote the registry, one of them from a stale cache. Managed mode leaked host folder names. Paths were redacted but names were not, and names default to the folder basename — so a data directory previously used in free mode listed every folder the operator had ever opened, through the API whose whole job is hiding host paths. A managed deployment now exposes exactly one Workspace: the active one. The rest are unaddressable there anyway. Managed bootstrap pulled storage open through the on-demand path. Simplification
Deliberately left alone
Verification
|
Follow-ups from review discussion. - `WorkspaceHandle` carries identity and display name only. A directory is a materialization fact, not an identity one, and a structured backend that keeps Workspaces in a database has no path to name — the port previously required one, so such an adapter could only satisfy it by inventing a path its own doc forbade. Locating and adopting now resolve in the composition root as the Workspace-level twin of `spaceDirectory()` (`adoptWorkspaceDirectory`, `workspaceAtDirectory`, `workspaceDirectory`), where a non-materializing profile refuses outright. `workspace.ts` holds the active identity and the active path as the two separate facts they are. - The manifest moves from `<workspace>/.huabu/workspace.json` to `<workspace>/workspace.json`, alongside the `space.json` each Space keeps. This drops the tool-branded path the demo-stage rename had been removing and makes the Workspace's own record as discoverable as a Space's. Nothing has shipped with the hidden layout, so no migration is needed. - The manifest schema now guards the write as well as the read, so it is the only definition of a valid manifest. That replaces `rename()`'s hand-written empty-name check — a second copy of a rule the schema already owned — and makes an unusable name fail where it is set instead of on a later read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR
|
Follow-up on the three things the earlier review had deliberately left open — all three now resolved in 9671dd3. Location is out of the identity port. The manifest lost the The name rule has one owner. Rather than keep a hand-written empty-name check next to a schema that already declared Verification
|
Keeps the manifest at the Home folder root — no tool-branded directory — but hidden, the way the Workspace's other Huabu-owned state such as `.world/` is, so the user's Spaces and `setting/` remain the visible contents. It also stops the name colliding with the unrelated Electron-owned `workspace.json` in the desktop `userData` tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR
Recovering a remembered Home folder is a claim about the past, not a request to open one, so the import now only registers membership. Preparing every remembered path recreated folders the user had since deleted — as fully initialized Workspaces indistinguishable from real ones in the picker — ran the whole on-disk migration chain against Workspaces nobody asked for, and held the entire collection behind one preparation fork per entry, each with its own 70s timeout, while the app sat on the loading screen. Preparation belongs to the activation the user actually performs, which still migrates a legacy Home folder the first time it is opened. Precedence is now resolved in most-recently-used order before anything is adopted. When two remembered paths name one copied Workspace only the first can hold the identity, and importing oldest-first handed it to the stale backup and dropped the folder actually in use — which the restore then opened as if it were theirs. Asking each directory what identity it already claims needs a read the storage module owns, so `workspaceIdentityOnDisk()` joins the Workspace materialization surface rather than the legacy importer reaching past the storage boundary for the manifest. On the client, a registry that cannot be listed is fatal only when it is the sole route back to a Workspace; an already-activated Server stays usable and just loses its welcome list. Adds an end-to-end suite over the real routes, registry, and manifests that walks the upgrade a user actually sees: history recovered most-recent-first, folders they deleted left deleted, folders they did not open left untouched, and the deprecated file never consulted again once the registry exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCuWpzgCDK7bTofSJkqPWT
Behavior changes
<workspace>/.workspace.jsonis missing, Huabu creates it with a stableworkspaceIdand display name; this compatibility path applies to both free and managed deployments. The manifest sits at the Home folder root, hidden the way the Workspace's other Huabu-owned state such as.world/is, so the user's Spaces andsetting/remain the visible contents.<HUABU_DATA_DIR>/storage/disk/workspaces.json. The registry stores onlyworkspaceIdand absoluteworkspacePath; name and identity metadata remain authoritative in each Workspace's own manifest, and the manifest does not store its path.GET /api/workspacesPOST /api/workspacesGET /api/workspaces/:workspaceIdPOST /api/workspaces/:workspaceId/activatePATCH /api/workspaces/:workspaceIdDELETE /api/workspaces/:workspaceId/api/workspacebehavior and current UI remain compatible. Its response now also includes the stableworkspaceId, while the old Home-folder selection flow continues to work.nameis now the persisted display name rather than a live basename, so renaming the Home folder outside Huabu no longer changes the label.Recovering from external changes
The registry has to survive the user rearranging folders behind Huabu's back, so unreachable and replaced members are ordinary states rather than errors:
Implementation
WorkspaceHandleandWorkspaceRepositorycontracts alongside Space and blob storage. A handle carries identity and display name only: where a Workspace is is a materialization fact, not an identity one, so a backend that keeps Workspaces in a database is never asked to invent a path. Locating and adopting resolve in the composition root as the Workspace-level twin ofspaceDirectory()—adoptWorkspaceDirectory(),workspaceAtDirectory(),workspaceDirectory()— where a non-materializing profile refuses outright. Workspace identity is also a precondition of storage rather than a product of it — managed mode adopts its Workspace whileapp.tsis still evaluating, before the boot sequence can awaitinitStorage()— so the composition root resolves the repository on its own axis viagetWorkspaceRepository()instead of hanging it offStructuredStore. A future backend whose Workspace membership lives in a connection has to make adoption part of that awaited startup rather than widen the on-demand path.There is no UI for the plural API yet; this PR is the server contract it will be built against.
Verification
pnpm run check