Skip to content

Add durable workspace management - #130

Merged
cxxxxxn (cxxxxxn) merged 9 commits into
microsoft:mainfrom
ultmaster:feat/workspace-handles
Aug 27, 2026
Merged

Add durable workspace management#130
cxxxxxn (cxxxxxn) merged 9 commits into
microsoft:mainfrom
ultmaster:feat/workspace-handles

Conversation

@ultmaster

@ultmaster Yuge Zhang (ultmaster) commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Behavior changes

  • Existing disk Home folders are automatically adopted as Workspaces. If <workspace>/.workspace.json is missing, Huabu creates it with a stable workspaceId and 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 and setting/ remain the visible contents.
  • Registered disk Workspaces now survive server restarts through <HUABU_DATA_DIR>/storage/disk/workspaces.json. The registry stores only workspaceId and absolute workspacePath; name and identity metadata remain authoritative in each Workspace's own manifest, and the manifest does not store its path.
  • Moving a Workspace directory outside Huabu is recognized when the new path is opened and updates the durable path index. Two live directories carrying the same manifest identity are rejected as a copied-Workspace conflict instead of being treated as one Workspace.
  • A new plural management API supports listing, registering, reading, activating, renaming, and unregistering Workspaces:
    • GET /api/workspaces
    • POST /api/workspaces
    • GET /api/workspaces/:workspaceId
    • POST /api/workspaces/:workspaceId/activate
    • PATCH /api/workspaces/:workspaceId
    • DELETE /api/workspaces/:workspaceId
  • Activating a registered Workspace switches the running server without requiring a restart. Unregistering is non-destructive: it removes only registry membership, never the Workspace directory or manifest, and the active Workspace cannot be unregistered.
  • Free-mode mutations remain localhost-only. A managed deployment exposes exactly one Workspace — the active one — read-only and with its filesystem path redacted. Other registrations in the same data directory are unaddressable there, so listing them would leak host folder names through the API that redacts host paths.
  • The existing singular /api/workspace behavior and current UI remain compatible. Its response now also includes the stable workspaceId, while the old Home-folder selection flow continues to work. name is 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:

  • A registered Workspace whose folder was deleted, whose volume is unmounted, or whose path another Workspace has taken over reads as "not a member right now". It is skipped by the collection instead of failing it, so one unplugged drive cannot break the listing needed to unregister it, and it returns when its volume does. Unregistering works while a Workspace is unreachable.
  • A path whose folder was deleted and recreated is re-adopted under the identity now on disk, in the same process and across restarts. Recreating a moved Workspace's old path frees the moved copy to keep its identity at its new location.
  • A malformed manifest or registry still fails loudly. That is damage the operator has to see, not a folder that stepped out.

Implementation

  • Introduces storage-owned WorkspaceHandle and WorkspaceRepository contracts 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 of spaceDirectory()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 while app.ts is still evaluating, before the boot sequence can await initStorage() — so the composition root resolves the repository on its own axis via getWorkspaceRepository() instead of hanging it off StructuredStore. 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.
  • The durable index is the single in-process representation of membership: re-read from disk on access, with each member's display metadata read back from its own manifest on demand, so nothing can go stale against the files it describes.
  • The Server process is the registry's only writer. The isolated preparation child adopts the manifest — that is part of the blocking filesystem work it exists to contain — but never registers membership.
  • The manifest schema guards the write as well as the read, so it is the single definition of a valid manifest and a caller cannot persist a name that would fail validation on the next read.
  • The workspace operation-lease guard runs before adoption, so a refused workspace switch leaves the target untouched on disk and in the registry.
  • Adds isolated test data directories so persistent workspace registries cannot leak into the repository or between parallel server test files.
  • Updates the storage architecture documentation for Workspace identity and discovery ownership.

There is no UI for the plural API yet; this PR is the server contract it will be built against.

Verification

  • pnpm run check

Yuge Zhang (ultmaster) and others added 4 commits August 22, 2026 10:20
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
@ultmaster

Yuge Zhang (ultmaster) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

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 fixed

One unreachable Workspace took down the whole collection. list() hydrated every registered member through a manifest read that threw on ENOENT, so a deleted folder or an unplugged external drive made GET /api/workspaces fail — the endpoint you would need to unregister the broken entry. Unreachable members (folder gone, volume unmounted, path taken over by another Workspace) now read as "not a member right now": skipped by the collection, still unregisterable, and back when their volume returns. A malformed manifest or registry still throws, because that is damage the operator has to see rather than a folder that stepped out.

A deleted-and-recreated path became permanently un-openable. Selecting such a Home folder failed with Workspace identity at <p> changed from X to Y in the same process, or Workspace registry maps <p> to X, but that path claims Y after a restart — with no recovery short of hand-editing workspaces.json. That contradicted the compatibility promise that the old Home-folder selection flow keeps working. The path is now re-adopted under the identity on disk, and recreating a moved Workspace's old path frees the moved copy to keep its identity at its new location. Two live paths claiming one identity are still refused as a copy.

The operation-lease guard ran after irreversible side effects. commitWorkspacePath became commitWorkspaceHandle(open(path)), and open() writes the manifest and the registry before assertWorkspacePathChangeAllowed gets a chance to refuse. A rejected switch left the target adopted and registered — verified before the fix, with the rejected Workspace present in workspaces.json and carrying a .huabu/workspace.json it never asked for. The deleted "this function intentionally performs no disk I/O" comment was load-bearing; the guard now runs first.

Two processes wrote the registry, one of them from a stale cache. prepareWorkspaceOnDisk runs inside the isolated preparation child and called workspaces().open() there, while the parent memoized the registry on first load and never re-read it. Happy paths converged only because the parent opened the same path immediately afterwards. The child now adopts the manifest via ensureWorkspaceManifestOnDisk and registers nothing, leaving the Server process as the only writer.

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. initWorkspaceFromEnv() runs while app.ts is still evaluating, so reaching the repository through getStructuredStore() forced ensure() — the path storage.ts explicitly refuses for a backend with connections to hold, and the reason initStorage had grown a reuse branch. Workspace identity is a precondition of storage rather than a product of it, so the composition root now resolves the repository on its own axis via getWorkspaceRepository(), and the initStorage workaround is reverted. A future backend whose Workspace membership lives in a connection has to make adoption part of the awaited startup instead.

Simplification

  • The repository held four overlapping views of one id ↔ path fact (#byId, #byPath, #registeredPathById, #registeredIdByPath) on top of the file and the manifest, and every bug above lived in the reconciliation between them. It is now one thing: the durable index, re-read on access, with display metadata read back from each Workspace's own manifest on demand. For a collection of a handful of entries that costs a few small JSON reads and removes the entire staleness axis.
  • workspaces() came off the StructuredStore port — Workspace membership was never part of the Space store — which reverted structured-store.ts and ports/structured.ts to their base state.
  • Dropped what had no caller left: getWorkspaceName(), the exported commitWorkspaceHandle, the lease's duplicate workspaceId axis (the repository already guarantees the pairing), a no-op updateActiveWorkspaceHandle call, and the web route builders for an API with no client yet. WorkspaceCreateRequest / WorkspaceRenameRequest are now the route body types, matching how the singular route is written.

Deliberately left alone

  • WorkspaceHandle.workspacePath in a "backend-neutral" port. The port's own doc concedes only Disk can honor it, and there is one implementation — but this matches the house ports/ pattern, and narrowing it is a design call for whoever writes the second adapter.
  • The .huabu/ directory name. migrate-canvas-to-space.ts recently moved the product away from that vocabulary (setting/.huabu.mdsetting/user.md), so a new .huabu/ runs against that grain. Worth a conscious call rather than a silent rename.
  • DiskWorkspaceRepository.rename()'s empty-name throw, which the route's schema already makes unreachable. It guards the manifest's own schema against a repository caller, so it stays.

Verification

pnpm run check. Server 1010 passed / 21 skipped / 2 todo, web 1080 passed, shared 370 passed; typecheck, eslint, and prettier clean. Six regression tests added, plus one rewritten: unreachable-member tolerance, malformed-manifest still fatal, replaced-path re-adoption, moved-Workspace path reuse, refused-switch leaves no trace, single registry writer, and managed-mode narrowing. One test was removed with the initStorage workaround it covered.

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
@ultmaster

Yuge Zhang (ultmaster) commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

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. WorkspaceHandle is { workspaceId, name }. The old shape required workspacePath on the neutral contract while its own doc said a non-directory adapter "must extend this locator contract rather than manufacture a fake filesystem path" — which that adapter could not do, because the required field left it no way to comply. Where a Workspace is is a materialization fact, so it resolves in the composition root as the Workspace-level twin of spaceDirectory(): adoptWorkspaceDirectory(), workspaceAtDirectory(), workspaceDirectory(), with a non-materializing profile refusing outright instead of returning a path that does not exist. The Disk repository keeps adopt() / at() / directoryOf() as its own locator surface behind that. workspace.ts now holds the active identity and the active path as two separate module facts, which is the honest split: a database-backed Workspace would have the first and not the second.

The manifest lost the .huabu/ directory. It is <workspace>/.workspace.json now: at the Home folder root rather than inside a tool-branded directory, and hidden the way the Workspace's other Huabu-owned state such as .world/ is, so the user's Spaces and setting/ stay the visible contents. That also stops pulling against the demo-stage rename that took setting/.huabu.md to setting/user.md, and avoids colliding with the unrelated Electron-owned workspace.json in the desktop userData tree. Nothing has shipped with the old layout, so there is no migration to write. Verified it does not disturb Space discovery: scanWorkspace() only descends into directories, so a root-level file is skipped either way.

The name rule has one owner. Rather than keep a hand-written empty-name check next to a schema that already declared z.string().trim().min(1), the manifest schema now validates on write as well as on read. rename() just writes through it, so an unusable name fails where it is set instead of persisting and surfacing later as a "malformed manifest", the trim happens once in the place that defines it, and there is no second copy of the rule to drift.

Verification

pnpm run check. Server 1011 passed / 21 skipped / 2 todo, web 1080 passed, shared 370 passed; typecheck, eslint, prettier clean. One test added for the schema-owned name rule (whitespace-only refused and the manifest left unchanged; a name needing only a trim accepted and normalized once); the existing suites were updated to the split identity/locator shape rather than dropped.

Yuge Zhang (ultmaster) and others added 2 commits August 24, 2026 16:33
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
Yuge Zhang (ultmaster) and others added 2 commits August 26, 2026 14:43
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
@cxxxxxn
cxxxxxn (cxxxxxn) merged commit ed554d8 into microsoft:main Aug 27, 2026
2 checks passed
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.

2 participants