From c65718841ae2c401eb62a9b924f39545b2934475 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Sat, 22 Aug 2026 10:20:14 +0800 Subject: [PATCH 1/9] feat(storage): add workspace repository --- .../storage/backends/disk/structured-store.ts | 6 + .../disk/workspace-repository.test.ts | 109 +++++++++++++ .../backends/disk/workspace-repository.ts | 150 ++++++++++++++++++ apps/server/src/modules/storage/index.ts | 4 + .../src/modules/storage/ports/structured.ts | 3 + .../src/modules/storage/ports/workspace.ts | 28 ++++ .../src/modules/storage/profile.test.ts | 17 ++ apps/server/src/modules/storage/storage.ts | 10 +- .../src/modules/workspace-activation.test.ts | 14 +- .../src/modules/workspace-managed.test.ts | 35 ++++ apps/server/src/modules/workspace-prepare.ts | 8 +- .../src/modules/workspace.route.test.ts | 33 ++++ apps/server/src/modules/workspace.route.ts | 15 +- apps/server/src/modules/workspace.test.ts | 15 ++ apps/server/src/modules/workspace.ts | 56 +++++-- apps/web/src/store/workspaceStore.ts | 4 + docs/architecture/canvas-storage.md | 2 + packages/shared/src/types/api/workspace.ts | 4 +- 18 files changed, 486 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts create mode 100644 apps/server/src/modules/storage/backends/disk/workspace-repository.ts create mode 100644 apps/server/src/modules/storage/ports/workspace.ts create mode 100644 apps/server/src/modules/workspace-managed.test.ts diff --git a/apps/server/src/modules/storage/backends/disk/structured-store.ts b/apps/server/src/modules/storage/backends/disk/structured-store.ts index e02f25d49..4d63af98a 100644 --- a/apps/server/src/modules/storage/backends/disk/structured-store.ts +++ b/apps/server/src/modules/storage/backends/disk/structured-store.ts @@ -28,12 +28,14 @@ import { createDiskSpaceRecordReader } from './space-record.js'; import { DiskSpaceRepository } from './space-repository.js'; import { DiskSpaceTasks } from './space-tasks.js'; import { createDiskSpaceWrite } from './space-write.js'; +import { DiskWorkspaceRepository } from './workspace-repository.js'; import type { StorageHealth } from '../../ports/common.js'; import type { SpaceHandle, StructuredStore } from '../../ports/structured.js'; export class DiskStructuredStore implements StructuredStore { readonly kind = 'disk' as const; + readonly #workspaces = new DiskWorkspaceRepository(); async init(): Promise { // The workspace directory is prepared by `workspace-prepare.ts`; Space @@ -46,6 +48,10 @@ export class DiskStructuredStore implements StructuredStore { async close(): Promise {} + workspaces(): DiskWorkspaceRepository { + return this.#workspaces; + } + spaces(): DiskSpaceRepository { return new DiskSpaceRepository(); } diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts new file mode 100644 index 000000000..b16d8ea5c --- /dev/null +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { + DiskWorkspaceRepository, + WORKSPACE_MANIFEST_DIR, + WORKSPACE_MANIFEST_FILENAME, +} from './workspace-repository.js'; + +describe('DiskWorkspaceRepository', () => { + const roots: string[] = []; + + function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + roots.push(dir); + return dir; + } + + function manifestPath(root: string): string { + return path.join(root, WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME); + } + + afterAll(() => { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('adopts a legacy Workspace by creating a stable hidden manifest', () => { + const root = tempDir('huabu-legacy-workspace-'); + const firstRepository = new DiskWorkspaceRepository(); + const first = firstRepository.open(root); + + expect(first.workspacePath).toBe(path.resolve(root)); + expect(first.name).toBe(path.basename(root)); + expect(first.workspaceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + + const persisted = JSON.parse(readFileSync(manifestPath(root), 'utf8')) as { + schemaVersion: number; + workspaceId: string; + name: string; + }; + expect(persisted).toEqual({ + schemaVersion: 1, + workspaceId: first.workspaceId, + name: path.basename(root), + }); + + const reopened = new DiskWorkspaceRepository().open(root); + expect(reopened).toEqual(first); + }); + + it('indexes opened Workspaces by both stable id and canonical path', () => { + const repository = new DiskWorkspaceRepository(); + const first = repository.open(tempDir('huabu-workspace-first-')); + const second = repository.open(tempDir('huabu-workspace-second-')); + + expect(repository.get(first.workspaceId)).toEqual(first); + expect(repository.getByPath(first.workspacePath)).toEqual(first); + expect(repository.list()).toEqual([first, second]); + }); + + it('rejects two different paths that claim the same Workspace identity', () => { + const firstRoot = tempDir('huabu-workspace-original-'); + const secondRoot = tempDir('huabu-workspace-copy-'); + const repository = new DiskWorkspaceRepository(); + const first = repository.open(firstRoot); + + mkdirSync(path.dirname(manifestPath(secondRoot)), { recursive: true }); + writeFileSync( + manifestPath(secondRoot), + JSON.stringify({ + schemaVersion: 1, + workspaceId: first.workspaceId, + name: 'Copied Workspace', + }), + 'utf8', + ); + + expect(() => repository.open(secondRoot)).toThrow( + /same Workspace identity.*different paths/i, + ); + }); + + it('rejects a malformed existing manifest instead of replacing it', () => { + const root = tempDir('huabu-workspace-corrupt-'); + mkdirSync(path.dirname(manifestPath(root)), { recursive: true }); + writeFileSync(manifestPath(root), '{ definitely not json', 'utf8'); + + expect(() => new DiskWorkspaceRepository().open(root)).toThrow( + /workspace manifest/i, + ); + expect(readFileSync(manifestPath(root), 'utf8')).toBe( + '{ definitely not json', + ); + }); +}); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts new file mode 100644 index 000000000..0c056fff2 --- /dev/null +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Disk implementation of the Workspace storage port. + * + * A Workspace owns one stable id in `/.huabu/workspace.json`. + * Existing Home folders predate that manifest, so opening one adopts it by + * creating the file once. The repository then indexes immutable handles by + * both id and canonical path; it never treats a copied manifest as two + * Workspaces with the same identity. + */ + +import { randomUUID } from 'node:crypto'; +import { + mkdirSync, + readFileSync, + writeFileSync, + type WriteFileOptions, +} from 'node:fs'; +import path from 'node:path'; + +import { z } from 'zod'; + +import type { + WorkspaceHandle, + WorkspaceRepository, +} from '../../ports/workspace.js'; + +export const WORKSPACE_MANIFEST_DIR = '.huabu'; +export const WORKSPACE_MANIFEST_FILENAME = 'workspace.json'; +const WORKSPACE_MANIFEST_SCHEMA_VERSION = 1; + +const workspaceManifestSchema = z.object({ + schemaVersion: z.literal(WORKSPACE_MANIFEST_SCHEMA_VERSION), + workspaceId: z.string().uuid(), + name: z.string().trim().min(1), +}); + +export type WorkspaceManifest = z.infer; + +function manifestPath(workspacePath: string): string { + return path.join( + workspacePath, + WORKSPACE_MANIFEST_DIR, + WORKSPACE_MANIFEST_FILENAME, + ); +} + +function defaultWorkspaceName(workspacePath: string): string { + return path.basename(workspacePath) || 'Workspace'; +} + +function readManifest(filePath: string): WorkspaceManifest { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + throw new Error( + `Workspace manifest at ${filePath} could not be read: ${(error as Error).message}`, + ); + } + + const result = workspaceManifestSchema.safeParse(parsed); + if (!result.success) { + throw new Error( + `Workspace manifest at ${filePath} is invalid: ${result.error.issues[0]?.message ?? 'invalid manifest'}`, + ); + } + return result.data; +} + +function isAlreadyExists(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'; +} + +/** + * Return the persisted Workspace identity, adopting a legacy folder when the + * manifest is absent. `wx` keeps concurrent adopters from overwriting the + * winner; every contender then reads the same durable identity. + */ +export function ensureWorkspaceManifestOnDisk( + rawWorkspacePath: string, +): WorkspaceManifest { + const workspacePath = path.resolve(rawWorkspacePath); + const metadataDir = path.join(workspacePath, WORKSPACE_MANIFEST_DIR); + const filePath = manifestPath(workspacePath); + mkdirSync(metadataDir, { recursive: true }); + + const manifest: WorkspaceManifest = { + schemaVersion: WORKSPACE_MANIFEST_SCHEMA_VERSION, + workspaceId: randomUUID(), + name: defaultWorkspaceName(workspacePath), + }; + const options: WriteFileOptions = { encoding: 'utf8', flag: 'wx' }; + try { + writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`, options); + } catch (error) { + if (!isAlreadyExists(error)) throw error; + } + + return readManifest(filePath); +} + +export class DiskWorkspaceRepository implements WorkspaceRepository { + readonly #byId = new Map(); + readonly #byPath = new Map(); + + open(rawWorkspacePath: string): WorkspaceHandle { + const workspacePath = path.resolve(rawWorkspacePath); + const manifest = ensureWorkspaceManifestOnDisk(workspacePath); + const existingAtPath = this.#byPath.get(workspacePath); + if (existingAtPath) { + if (existingAtPath.workspaceId !== manifest.workspaceId) { + throw new Error( + `Workspace identity at ${workspacePath} changed from ${existingAtPath.workspaceId} to ${manifest.workspaceId}`, + ); + } + return existingAtPath; + } + + const existingWithId = this.#byId.get(manifest.workspaceId); + if (existingWithId && existingWithId.workspacePath !== workspacePath) { + throw new Error( + `The same Workspace identity ${manifest.workspaceId} was opened from different paths: ${existingWithId.workspacePath} and ${workspacePath}`, + ); + } + + const handle: WorkspaceHandle = Object.freeze({ + workspaceId: manifest.workspaceId, + workspacePath, + name: manifest.name, + }); + this.#byId.set(handle.workspaceId, handle); + this.#byPath.set(handle.workspacePath, handle); + return handle; + } + + get(workspaceId: string): WorkspaceHandle | null { + return this.#byId.get(workspaceId) ?? null; + } + + getByPath(rawWorkspacePath: string): WorkspaceHandle | null { + return this.#byPath.get(path.resolve(rawWorkspacePath)) ?? null; + } + + list(): readonly WorkspaceHandle[] { + return [...this.#byId.values()]; + } +} diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 88d251d1b..c9050beb8 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -92,6 +92,10 @@ export type { BlobStore, } from './ports/blob.js'; export type { StorageHealth } from './ports/common.js'; +export type { + WorkspaceHandle, + WorkspaceRepository, +} from './ports/workspace.js'; export type { NewCanvasEvent, NodeDeleteResult, diff --git a/apps/server/src/modules/storage/ports/structured.ts b/apps/server/src/modules/storage/ports/structured.ts index 274074304..6bb0b2985 100644 --- a/apps/server/src/modules/storage/ports/structured.ts +++ b/apps/server/src/modules/storage/ports/structured.ts @@ -37,6 +37,7 @@ */ import type { StorageHealth } from './common.js'; +import type { WorkspaceRepository } from './workspace.js'; import type { CanvasEvent, CanvasFile, @@ -69,6 +70,8 @@ export interface StructuredStore { init(): Promise; health(): Promise; close(): Promise; + /** Return the repository for Workspace membership and identity. */ + workspaces(): WorkspaceRepository; /** * Return a repository for the currently-bound Space collection. * diff --git a/apps/server/src/modules/storage/ports/workspace.ts b/apps/server/src/modules/storage/ports/workspace.ts new file mode 100644 index 000000000..8510986b9 --- /dev/null +++ b/apps/server/src/modules/storage/ports/workspace.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Workspace storage port — membership and stable Workspace identity. + * + * A Workspace is the namespace that owns Spaces. The repository manages that + * collection; a handle identifies one member and carries the materialized path + * used by the Disk adapter. A database adapter can retain the same identity and + * repository shape while replacing the path with its own materialization + * capability when that backend is implemented. + * + * This file may not import a backend implementation or application workspace + * lifecycle policy. + */ + +export interface WorkspaceHandle { + readonly workspaceId: string; + readonly workspacePath: string; + readonly name: string; +} + +export interface WorkspaceRepository { + open(workspacePath: string): WorkspaceHandle; + get(workspaceId: string): WorkspaceHandle | null; + getByPath(workspacePath: string): WorkspaceHandle | null; + list(): readonly WorkspaceHandle[]; +} diff --git a/apps/server/src/modules/storage/profile.test.ts b/apps/server/src/modules/storage/profile.test.ts index 18be15c91..addf226a6 100644 --- a/apps/server/src/modules/storage/profile.test.ts +++ b/apps/server/src/modules/storage/profile.test.ts @@ -9,6 +9,7 @@ import { StorageProfileError, validateStorageProfile, } from './profile.js'; +import { createStorage, initStorage, setStorageForTesting } from './storage.js'; describe('parseStorageProfile', () => { it('defaults both axes to disk', () => { @@ -99,3 +100,19 @@ describe('requiresExplicitInit', () => { expect(requiresExplicitInit(profile)).toBe(true); }); }); + +describe('storage initialization', () => { + it('keeps adapters first used during managed Workspace bootstrap', async () => { + const profile = { + structured: { kind: 'disk' as const }, + blobs: { kind: 'disk' as const }, + }; + const storage = createStorage(profile); + const restore = setStorageForTesting(storage); + try { + expect(await initStorage(profile)).toBe(storage); + } finally { + restore(); + } + }); +}); diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index c11a93e0b..3c9a67bb6 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -186,7 +186,15 @@ function ensure(): Storage { export async function initStorage( profile: StorageProfile = parseStorageProfile(), ): Promise { - const storage = createStorage(profile); + const storage = current ?? createStorage(profile); + if ( + storage.profile.structured.kind !== profile.structured.kind || + storage.profile.blobs.kind !== profile.blobs.kind + ) { + throw new StorageProfileError( + 'Storage was initialized with a different profile than the adapters already in use.', + ); + } await Promise.all([storage.structured.init(), storage.blobs.init()]); current = storage; return storage; diff --git a/apps/server/src/modules/workspace-activation.test.ts b/apps/server/src/modules/workspace-activation.test.ts index ac05eadca..186729ec8 100644 --- a/apps/server/src/modules/workspace-activation.test.ts +++ b/apps/server/src/modules/workspace-activation.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -67,6 +67,18 @@ describe('workspace activation isolation', () => { expect(getWorkspacePath()).toBe(path.resolve(previous)); }); + it('adopts a legacy path after an isolated worker succeeds', async () => { + const target = tempDir('huabu-workspace-adopted-'); + const workerPath = worker(`process.send({ ok: true });`); + + await activateWorkspacePath(target, { workerPath, timeoutMs: 1_000 }); + + expect(existsSync(path.join(target, '.huabu', 'workspace.json'))).toBe( + true, + ); + expect(getWorkspacePath()).toBe(path.resolve(target)); + }); + it('rejects a concurrent activation while preparation is running', async () => { const workerPath = worker(`setInterval(() => {}, 1_000);`); const first = activateWorkspacePath(tempDir('huabu-workspace-first-'), { diff --git a/apps/server/src/modules/workspace-managed.test.ts b/apps/server/src/modules/workspace-managed.test.ts new file mode 100644 index 000000000..5a49901a9 --- /dev/null +++ b/apps/server/src/modules/workspace-managed.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, expect, it, vi } from 'vitest'; + +const roots: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +it('adopts a legacy managed Workspace without exposing its host path', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'huabu-managed-workspace-')); + roots.push(root); + vi.stubEnv('HUABU_WORKSPACE', root); + vi.resetModules(); + + const workspace = await import('./workspace.js'); + workspace.initWorkspaceFromEnv(); + + expect(workspace.isManagedMode()).toBe(true); + expect(workspace.getWorkspaceHandle()).toMatchObject({ + workspacePath: path.resolve(root), + name: path.basename(root), + }); + expect(existsSync(path.join(root, '.huabu', 'workspace.json'))).toBe(true); +}); diff --git a/apps/server/src/modules/workspace-prepare.ts b/apps/server/src/modules/workspace-prepare.ts index c04514d40..c7a9e5838 100644 --- a/apps/server/src/modules/workspace-prepare.ts +++ b/apps/server/src/modules/workspace-prepare.ts @@ -11,7 +11,10 @@ import { mkdirSync } from 'node:fs'; -import { ensureWorldCanvasOnDisk } from './storage/index.js'; +import { + ensureWorldCanvasOnDisk, + getStructuredStore, +} from './storage/index.js'; import { migrateLegacyAcpSessions } from './workspace/migrations/migrate-acp-sessions.js'; import { migrateLegacyAgenetesThreads, @@ -31,6 +34,9 @@ import { renderExternalAgentSystemPreamble } from '../prompt/external-agent/syst */ export function prepareWorkspaceOnDisk(workspacePath: string): void { mkdirSync(workspacePath, { recursive: true }); + // Adopt Home folders created by older Huabu versions before any other + // migration runs. Managed and free mode therefore share one identity path. + getStructuredStore().workspaces().open(workspacePath); // Demo-stage rename: canvas.json -> space.json, .memory/canvas.md -> // .memory/space.md, setting/.huabu.md -> setting/user.md. Runs first so // later readers / migrations see the new names. DELETE-ME later. diff --git a/apps/server/src/modules/workspace.route.test.ts b/apps/server/src/modules/workspace.route.test.ts index faea3e614..bd253e516 100644 --- a/apps/server/src/modules/workspace.route.test.ts +++ b/apps/server/src/modules/workspace.route.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const workspaceState = vi.hoisted(() => ({ configured: false, managed: false, + workspaceId: '00000000-0000-4000-8000-000000000001', path: '/tmp/sediment-workspace-route', name: 'sediment-workspace-route', })); @@ -15,6 +16,14 @@ vi.mock('./workspace.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + getWorkspaceHandle: () => + workspaceState.configured + ? { + workspaceId: workspaceState.workspaceId, + workspacePath: workspaceState.path, + name: workspaceState.name, + } + : null, getWorkspaceName: () => workspaceState.configured ? workspaceState.name : null, getWorkspacePath: () => workspaceState.path, @@ -60,6 +69,7 @@ async function buildApp() { beforeEach(() => { workspaceState.configured = false; workspaceState.managed = false; + workspaceState.workspaceId = '00000000-0000-4000-8000-000000000001'; workspaceState.path = '/tmp/sediment-workspace-route'; workspaceState.name = 'sediment-workspace-route'; worldId.mockReset().mockResolvedValue('world-id'); @@ -88,6 +98,7 @@ describe('workspace Space repository integration', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ configured: true, + workspaceId: '00000000-0000-4000-8000-000000000001', worldCanvasId: 'world-id', }); expect(spaces).toHaveBeenCalledTimes(1); @@ -105,6 +116,7 @@ describe('workspace Space repository integration', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ configured: false, + workspaceId: null, worldCanvasId: null, }); expect(getStructuredStore).not.toHaveBeenCalled(); @@ -125,6 +137,7 @@ describe('workspace Space repository integration', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ configured: true, + workspaceId: '00000000-0000-4000-8000-000000000001', path: '/tmp/new-workspace', name: 'new-workspace', worldCanvasId: 'world-id', @@ -152,4 +165,24 @@ describe('workspace Space repository integration', () => { await app.close(); } }); + + it('keeps managed-mode path privacy while returning the stable identity', async () => { + workspaceState.configured = true; + workspaceState.managed = true; + const app = await buildApp(); + try { + const response = await app.inject({ method: 'GET', url: '/workspace' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + mode: 'managed', + configured: true, + workspaceId: '00000000-0000-4000-8000-000000000001', + path: null, + name: 'sediment-workspace-route', + }); + } finally { + await app.close(); + } + }); }); diff --git a/apps/server/src/modules/workspace.route.ts b/apps/server/src/modules/workspace.route.ts index 1a0f98353..aa270ddc6 100644 --- a/apps/server/src/modules/workspace.route.ts +++ b/apps/server/src/modules/workspace.route.ts @@ -15,12 +15,7 @@ import { WorkspaceActivationInProgressError, WorkspaceActivationTimeoutError, } from './workspace-activation.js'; -import { - getWorkspaceName, - getWorkspacePath, - isManagedMode, - isWorkspaceConfigured, -} from './workspace.js'; +import { getWorkspaceHandle, isManagedMode } from './workspace.js'; import type { ApiErrorBody, @@ -155,14 +150,16 @@ function sendError( /** Build the canonical success payload describing the current workspace. */ async function buildWorkspaceState(): Promise { const managed = isManagedMode(); - const configured = isWorkspaceConfigured(); + const workspace = getWorkspaceHandle(); + const configured = workspace !== null; return { mode: managed ? 'managed' : 'free', configured, + workspaceId: workspace?.workspaceId ?? null, // Free-mode active absolute path. Never exposed in managed mode. - path: configured && !managed ? getWorkspacePath() : null, + path: workspace && !managed ? workspace.workspacePath : null, // Display label (basename). Safe to send in either mode. - name: configured ? getWorkspaceName() : null, + name: workspace?.name ?? null, worldCanvasId: configured ? await getStructuredStore().spaces().worldId() : null, diff --git a/apps/server/src/modules/workspace.test.ts b/apps/server/src/modules/workspace.test.ts index 882a36fde..623824c64 100644 --- a/apps/server/src/modules/workspace.test.ts +++ b/apps/server/src/modules/workspace.test.ts @@ -8,6 +8,7 @@ import path from 'node:path'; import { acquireWorkspaceOperationLease, commitWorkspacePath, + getWorkspaceHandle, getWorkspacePath, setWorkspacePath, WorkspaceOperationInProgressError, @@ -75,4 +76,18 @@ describe('workspace operation leases', () => { expect(existsSync(next)).toBe(true); expect(getWorkspacePath()).toBe(path.resolve(next)); }); + + it('keeps the active path and manifest identity in one Workspace handle', () => { + const current = tempDir('huabu-workspace-handle-'); + setWorkspacePath(current); + + const handle = getWorkspaceHandle(); + expect(handle).toMatchObject({ + workspacePath: path.resolve(current), + name: path.basename(current), + }); + expect(handle?.workspaceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); }); diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index 235042a92..de2bb762e 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -28,6 +28,7 @@ * Directory layout inside the active workspace (canvas-centric): * * / + * .huabu/workspace.json * / * space.json * nodes/.md @@ -40,13 +41,17 @@ import path from 'node:path'; import { resetExternalNoteSessions } from './canvas/external-watcher.js'; import { refreshCanvasDirIndex } from './storage/canvas-dirs.js'; +import { getStructuredStore } from './storage/index.js'; import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { invalidateUserSkill } from '../prompt/index.js'; +import type { WorkspaceHandle } from './storage/index.js'; + const ENV_KEY = 'HUABU_WORKSPACE'; -let _workspacePath: string | null = null; +let _workspaceHandle: WorkspaceHandle | null = null; let _managed = false; +let _leasedWorkspaceId: string | null = null; let _leasedWorkspacePath: string | null = null; let _workspaceOperationLeaseCount = 0; @@ -58,6 +63,7 @@ let _workspaceOperationLeaseCount = 0; * original result. */ export interface WorkspaceOperationLease { + readonly workspaceId: string; readonly workspacePath: string; release(): void; } @@ -85,7 +91,7 @@ export function isManagedMode(): boolean { } export function isWorkspaceConfigured(): boolean { - return _workspacePath !== null; + return _workspaceHandle !== null; } /** @@ -120,14 +126,19 @@ export function initWorkspaceFromEnv(): void { * Throws if no workspace has been activated yet. */ export function getWorkspacePath(): string { - if (!_workspacePath) { + if (!_workspaceHandle) { throw new Error( 'Workspace path has not been configured. ' + 'Activate a workspace first (PUT /api/workspace) or set ' + `${ENV_KEY} in the environment.`, ); } - return _workspacePath; + return _workspaceHandle.workspacePath; +} + +/** The active immutable Workspace identity, or null before configuration. */ +export function getWorkspaceHandle(): WorkspaceHandle | null { + return _workspaceHandle; } /** @@ -138,26 +149,37 @@ export function getWorkspacePath(): string { * same path remains allowed. */ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { - const workspacePath = getWorkspacePath(); + const workspace = getWorkspaceHandle(); + if (!workspace) { + throw new Error( + 'Workspace path has not been configured. ' + + 'Activate a workspace first (PUT /api/workspace) or set ' + + `${ENV_KEY} in the environment.`, + ); + } if ( _workspaceOperationLeaseCount > 0 && - _leasedWorkspacePath !== workspacePath + (_leasedWorkspaceId !== workspace.workspaceId || + _leasedWorkspacePath !== workspace.workspacePath) ) { throw new Error('Workspace operation lease invariant violated'); } - _leasedWorkspacePath = workspacePath; + _leasedWorkspaceId = workspace.workspaceId; + _leasedWorkspacePath = workspace.workspacePath; _workspaceOperationLeaseCount += 1; let released = false; return Object.freeze({ - workspacePath, + workspaceId: workspace.workspaceId, + workspacePath: workspace.workspacePath, release(): void { if (released) return; released = true; _workspaceOperationLeaseCount -= 1; if (_workspaceOperationLeaseCount === 0) { + _leasedWorkspaceId = null; _leasedWorkspacePath = null; } }, @@ -173,8 +195,7 @@ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { * the deployment treats the host filesystem as private. */ export function getWorkspaceName(): string | null { - if (!_workspacePath) return null; - return path.basename(_workspacePath); + return _workspaceHandle?.name ?? null; } /** @@ -206,12 +227,19 @@ export function resolveWorkspacePath(newPath: string): string { /** * Commit an already-prepared workspace to process-local state. * - * This function intentionally performs no disk I/O. Runtime activation calls - * it only after the isolated preparation process has completed successfully. + * Runtime activation calls this only after the isolated preparation process + * has completed successfully. Opening the handle reads the prepared manifest; + * the compatibility fallback creates it when an older caller committed a + * legacy path without going through preparation first. */ export function commitWorkspacePath(resolvedPath: string): void { - assertWorkspacePathChangeAllowed(resolvedPath); - _workspacePath = resolvedPath; + commitWorkspaceHandle(getStructuredStore().workspaces().open(resolvedPath)); +} + +/** Commit an already-prepared Workspace handle to process-local state. */ +export function commitWorkspaceHandle(workspace: WorkspaceHandle): void { + assertWorkspacePathChangeAllowed(workspace.workspacePath); + _workspaceHandle = workspace; // Drop the cached canvas-dir index so subsequent lookups (used by // migrations and route handlers) reflect the new workspace. refreshCanvasDirIndex(); diff --git a/apps/web/src/store/workspaceStore.ts b/apps/web/src/store/workspaceStore.ts index db6f43d5e..385f5a479 100644 --- a/apps/web/src/store/workspaceStore.ts +++ b/apps/web/src/store/workspaceStore.ts @@ -139,6 +139,8 @@ interface WorkspaceState { /** The active absolute workspace path (free mode), or null. */ workspacePath: string | null; + /** Stable server-owned Workspace identity, or null before configuration. */ + workspaceId: string | null; /** Display label (basename of the active workspace), or null. */ workspaceName: string | null; /** Stable hidden World canvas identity, or null before configuration. */ @@ -197,6 +199,7 @@ function fromInfo(info: WorkspaceInfo): Partial { return { mode: info.mode, capabilities: info.capabilities, + workspaceId: info.workspaceId, workspacePath: info.path, workspaceName: info.name, worldCanvasId: info.worldCanvasId, @@ -250,6 +253,7 @@ export const useWorkspaceStore = create()((set, get) => ({ typeof localStorage !== 'undefined' ? localStorage.getItem(FREE_PATH_KEY) : null, + workspaceId: null, workspaceName: null, worldCanvasId: null, worldEnabled: diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 80d28f505..7b098ef4a 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -12,6 +12,8 @@ Runtime Home-folder activation prepares and migrates the selected directory in a ``` / + .huabu/ + workspace.json # stable Workspace identity + display name .world/ # hidden workspace-owned World Canvas space.json # stable generated canvasId; normal Canvas topology setting/ # user-owned, cross-canvas diff --git a/packages/shared/src/types/api/workspace.ts b/packages/shared/src/types/api/workspace.ts index d370839ac..6f33e4148 100644 --- a/packages/shared/src/types/api/workspace.ts +++ b/packages/shared/src/types/api/workspace.ts @@ -32,9 +32,11 @@ export type WorkspaceCapabilities = z.infer; export const workspaceInfoSchema = z.object({ mode: workspaceModeSchema, configured: z.boolean(), + /** Stable Workspace identity, or null before configuration. */ + workspaceId: z.string().uuid().nullable(), /** Free-mode active absolute path. Always null in managed mode. */ path: z.string().nullable(), - /** Display label (basename of the active path), or null. */ + /** Persisted display label (defaults to the active path basename), or null. */ name: z.string().nullable(), /** Stable hidden World canvas identity, or null before configuration. */ worldCanvasId: z.string().min(1).nullable(), From d6f9f57bbbebed31162ef4b6007d239e71f961ec Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Sat, 22 Aug 2026 10:32:07 +0800 Subject: [PATCH 2/9] feat(server): add workspace management routes --- apps/server/src/app.ts | 2 + .../disk/workspace-repository.test.ts | 26 ++ .../backends/disk/workspace-repository.ts | 31 ++ .../src/modules/storage/ports/workspace.ts | 11 +- .../src/modules/workspace-activation.test.ts | 13 + .../src/modules/workspace-activation.ts | 16 +- apps/server/src/modules/workspace.ts | 14 + .../src/modules/workspaces.route.test.ts | 307 ++++++++++++++++++ apps/server/src/modules/workspaces.route.ts | 237 ++++++++++++++ apps/web/src/api/_routes.ts | 4 + packages/shared/src/types/api/workspace.ts | 23 ++ 11 files changed, 676 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/modules/workspaces.route.test.ts create mode 100644 apps/server/src/modules/workspaces.route.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a2d0fd579..201783461 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -62,6 +62,7 @@ import { isWorkspaceConfigured, } from './modules/workspace.js'; import workspaceRoutes from './modules/workspace.route.js'; +import workspacesRoutes from './modules/workspaces.route.js'; import { preloadSkills } from './prompt/index.js'; import { getPersistedSecret, setSecrets } from './security/secret-store.js'; import { MAX_UPLOAD_BYTES } from './upload-limits.js'; @@ -275,6 +276,7 @@ app.register(deploymentRoutes, { prefix: '/api/deployment' }); app.register(interactiveViewRoutes, { prefix: '/api/interactive-views' }); app.register(skillsRoutes, { prefix: '/api/skills' }); app.register(workspaceRoutes, { prefix: '/api/workspace' }); +app.register(workspacesRoutes, { prefix: '/api/workspaces' }); app.register(rfsRoutes, { prefix: '/api/rfs' }); app.register(agentTeamRoutes, { prefix: '/api/agent-team' }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index b16d8ea5c..8446b7ad7 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -106,4 +106,30 @@ describe('DiskWorkspaceRepository', () => { '{ definitely not json', ); }); + + it('renames a Workspace durably and updates both indexes', () => { + const root = tempDir('huabu-workspace-rename-'); + const repository = new DiskWorkspaceRepository(); + const original = repository.open(root); + + const renamed = repository.rename(original.workspaceId, 'Research'); + + expect(renamed).toEqual({ ...original, name: 'Research' }); + expect(repository.get(original.workspaceId)).toEqual(renamed); + expect(repository.getByPath(root)).toEqual(renamed); + expect(new DiskWorkspaceRepository().open(root)).toEqual(renamed); + }); + + it('unregisters a Workspace without deleting its manifest', () => { + const root = tempDir('huabu-workspace-remove-'); + const repository = new DiskWorkspaceRepository(); + const workspace = repository.open(root); + + expect(repository.remove(workspace.workspaceId)).toBe(true); + expect(repository.get(workspace.workspaceId)).toBeNull(); + expect(repository.getByPath(root)).toBeNull(); + expect(readFileSync(manifestPath(root), 'utf8')).toContain( + workspace.workspaceId, + ); + }); }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 0c056fff2..00090b609 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -22,6 +22,8 @@ import path from 'node:path'; import { z } from 'zod'; +import { atomicWriteJson } from '../../../../utils/fs.js'; + import type { WorkspaceHandle, WorkspaceRepository, @@ -147,4 +149,33 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { list(): readonly WorkspaceHandle[] { return [...this.#byId.values()]; } + + rename(workspaceId: string, rawName: string): WorkspaceHandle | null { + const current = this.#byId.get(workspaceId); + if (!current) return null; + + const name = rawName.trim(); + if (!name) throw new Error('Workspace name is required'); + const filePath = manifestPath(current.workspacePath); + const manifest = readManifest(filePath); + if (manifest.workspaceId !== workspaceId) { + throw new Error( + `Workspace identity at ${current.workspacePath} changed from ${workspaceId} to ${manifest.workspaceId}`, + ); + } + atomicWriteJson(filePath, { ...manifest, name }); + + const updated: WorkspaceHandle = Object.freeze({ ...current, name }); + this.#byId.set(workspaceId, updated); + this.#byPath.set(current.workspacePath, updated); + return updated; + } + + remove(workspaceId: string): boolean { + const current = this.#byId.get(workspaceId); + if (!current) return false; + this.#byId.delete(workspaceId); + this.#byPath.delete(current.workspacePath); + return true; + } } diff --git a/apps/server/src/modules/storage/ports/workspace.ts b/apps/server/src/modules/storage/ports/workspace.ts index 8510986b9..93fa74192 100644 --- a/apps/server/src/modules/storage/ports/workspace.ts +++ b/apps/server/src/modules/storage/ports/workspace.ts @@ -5,10 +5,10 @@ * Workspace storage port — membership and stable Workspace identity. * * A Workspace is the namespace that owns Spaces. The repository manages that - * collection; a handle identifies one member and carries the materialized path - * used by the Disk adapter. A database adapter can retain the same identity and - * repository shape while replacing the path with its own materialization - * capability when that backend is implemented. + * collection; a handle identifies one member and, for the currently implemented + * Disk profile, carries its materialized path. A non-directory structured + * adapter must extend this locator contract when it is implemented rather than + * manufacture a fake filesystem path. * * This file may not import a backend implementation or application workspace * lifecycle policy. @@ -25,4 +25,7 @@ export interface WorkspaceRepository { get(workspaceId: string): WorkspaceHandle | null; getByPath(workspacePath: string): WorkspaceHandle | null; list(): readonly WorkspaceHandle[]; + rename(workspaceId: string, name: string): WorkspaceHandle | null; + /** Forget one handle without deleting any Workspace-owned data. */ + remove(workspaceId: string): boolean; } diff --git a/apps/server/src/modules/workspace-activation.test.ts b/apps/server/src/modules/workspace-activation.test.ts index 186729ec8..fb3734438 100644 --- a/apps/server/src/modules/workspace-activation.test.ts +++ b/apps/server/src/modules/workspace-activation.test.ts @@ -7,6 +7,7 @@ import path from 'node:path'; import { activateWorkspacePath, + prepareWorkspacePath, runWorkspacePreparation, WorkspaceActivationInProgressError, WorkspaceActivationTimeoutError, @@ -67,6 +68,18 @@ describe('workspace activation isolation', () => { expect(getWorkspacePath()).toBe(path.resolve(previous)); }); + it('prepares a Workspace without changing the active Workspace', async () => { + const previous = tempDir('huabu-workspace-previous-'); + const next = tempDir('huabu-workspace-prepared-'); + const workerPath = worker(`process.send({ ok: true });`); + setWorkspacePath(previous); + + await expect( + prepareWorkspacePath(next, { workerPath, timeoutMs: 1_000 }), + ).resolves.toBe(path.resolve(next)); + expect(getWorkspacePath()).toBe(path.resolve(previous)); + }); + it('adopts a legacy path after an isolated worker succeeds', async () => { const target = tempDir('huabu-workspace-adopted-'); const workerPath = worker(`process.send({ ok: true });`); diff --git a/apps/server/src/modules/workspace-activation.ts b/apps/server/src/modules/workspace-activation.ts index ca2144d05..afc2d2e2f 100644 --- a/apps/server/src/modules/workspace-activation.ts +++ b/apps/server/src/modules/workspace-activation.ts @@ -176,11 +176,11 @@ export function runWorkspacePreparation( }); } -/** Prepare a free-mode workspace and commit it only after full success. */ -export async function activateWorkspacePath( +/** Prepare a free-mode Workspace without changing the active Workspace. */ +export async function prepareWorkspacePath( newPath: string, options: PreparationOptions = {}, -): Promise { +): Promise { if (isManagedMode()) { throw new Error( 'Server is in managed mode; the workspace is fixed at startup', @@ -194,8 +194,16 @@ export async function activateWorkspacePath( activationInProgress = true; try { await runWorkspacePreparation(resolvedPath, options); - commitWorkspacePath(resolvedPath); + return resolvedPath; } finally { activationInProgress = false; } } + +/** Prepare a free-mode workspace and commit it only after full success. */ +export async function activateWorkspacePath( + newPath: string, + options: PreparationOptions = {}, +): Promise { + commitWorkspacePath(await prepareWorkspacePath(newPath, options)); +} diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index de2bb762e..33e22b648 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -255,6 +255,20 @@ export function commitWorkspaceHandle(workspace: WorkspaceHandle): void { resetExternalNoteSessions(); } +/** Refresh metadata for the active Workspace without switching namespaces. */ +export function updateActiveWorkspaceHandle( + workspace: WorkspaceHandle, +): boolean { + if (_workspaceHandle?.workspaceId !== workspace.workspaceId) return false; + if (_workspaceHandle.workspacePath !== workspace.workspacePath) { + throw new Error( + 'Cannot change the active Workspace path during metadata update', + ); + } + _workspaceHandle = workspace; + return true; +} + // ────────────────────────────────────────────────────────────────────── // Internal helpers // ────────────────────────────────────────────────────────────────────── diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts new file mode 100644 index 000000000..2dd0257c9 --- /dev/null +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import fastify from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const FIRST_ID = '00000000-0000-4000-8000-000000000001'; +const SECOND_ID = '00000000-0000-4000-8000-000000000002'; +const NEW_ID = '00000000-0000-4000-8000-000000000003'; + +interface TestHandle { + workspaceId: string; + workspacePath: string; + name: string; +} + +const testState = vi.hoisted(() => ({ + managed: false, + active: null as TestHandle | null, + handles: [] as TestHandle[], +})); + +const storageMocks = vi.hoisted(() => ({ + resetStorageCache: vi.fn(), +})); + +const activationMocks = vi.hoisted(() => ({ + activateWorkspacePath: vi.fn<(path: string) => Promise>(), + prepareWorkspacePath: vi.fn<(path: string) => Promise>(), +})); + +const preprocessingMocks = vi.hoisted(() => ({ + resetPreprocessDispatcher: vi.fn(), +})); + +const repository = vi.hoisted(() => ({ + list: vi.fn(() => testState.handles), + get: vi.fn( + (workspaceId: string) => + testState.handles.find( + (workspace) => workspace.workspaceId === workspaceId, + ) ?? null, + ), + getByPath: vi.fn( + (workspacePath: string) => + testState.handles.find( + (workspace) => workspace.workspacePath === workspacePath, + ) ?? null, + ), + open: vi.fn((workspacePath: string) => { + const workspace = { + workspaceId: '00000000-0000-4000-8000-000000000003', + workspacePath, + name: workspacePath.split('/').filter(Boolean).at(-1) ?? 'Workspace', + }; + testState.handles.push(workspace); + return workspace; + }), + rename: vi.fn((workspaceId: string, name: string) => { + const index = testState.handles.findIndex( + (workspace) => workspace.workspaceId === workspaceId, + ); + if (index < 0) return null; + const workspace = { ...testState.handles[index], name } as TestHandle; + testState.handles[index] = workspace; + return workspace; + }), + remove: vi.fn((workspaceId: string) => { + const index = testState.handles.findIndex( + (workspace) => workspace.workspaceId === workspaceId, + ); + if (index < 0) return false; + testState.handles.splice(index, 1); + return true; + }), +})); + +vi.mock('./storage/index.js', () => ({ + getStructuredStore: () => ({ workspaces: () => repository }), + resetStorageCache: storageMocks.resetStorageCache, +})); + +vi.mock('./workspace.js', () => ({ + getWorkspaceHandle: () => testState.active, + isManagedMode: () => testState.managed, + resolveWorkspacePath: (workspacePath: string) => workspacePath, + updateActiveWorkspaceHandle: (workspace: TestHandle) => { + if (testState.active?.workspaceId !== workspace.workspaceId) return false; + testState.active = workspace; + return true; + }, +})); + +vi.mock('./workspace-activation.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + activateWorkspacePath: activationMocks.activateWorkspacePath, + prepareWorkspacePath: activationMocks.prepareWorkspacePath, + }; +}); + +vi.mock('./preprocessing/index.js', () => ({ + resetPreprocessDispatcher: preprocessingMocks.resetPreprocessDispatcher, +})); + +import workspacesRoutes from './workspaces.route.js'; + +import type * as WorkspaceActivationModule from './workspace-activation.js'; + +async function buildApp() { + const app = fastify(); + await app.register(workspacesRoutes, { prefix: '/workspaces' }); + await app.ready(); + return app; +} + +beforeEach(() => { + testState.managed = false; + testState.handles = [ + { + workspaceId: FIRST_ID, + workspacePath: '/tmp/first', + name: 'First', + }, + { + workspaceId: SECOND_ID, + workspacePath: '/tmp/second', + name: 'Second', + }, + ]; + testState.active = testState.handles[0] ?? null; + vi.clearAllMocks(); + activationMocks.prepareWorkspacePath.mockImplementation(async (path) => path); + activationMocks.activateWorkspacePath.mockImplementation(async (path) => { + testState.active = repository.getByPath(path); + }); +}); + +describe('plural Workspace management routes', () => { + it('lists registered Workspaces and marks the active one', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ method: 'GET', url: '/workspaces' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([ + { + workspaceId: FIRST_ID, + name: 'First', + path: '/tmp/first', + active: true, + }, + { + workspaceId: SECOND_ID, + name: 'Second', + path: '/tmp/second', + active: false, + }, + ]); + } finally { + await app.close(); + } + }); + + it('registers and prepares a Workspace without activating it', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'POST', + url: '/workspaces', + payload: { path: '/tmp/new', name: 'New Workspace' }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ + workspaceId: NEW_ID, + name: 'New Workspace', + path: '/tmp/new', + active: false, + }); + expect(activationMocks.prepareWorkspacePath).toHaveBeenCalledWith( + '/tmp/new', + ); + expect(testState.active?.workspaceId).toBe(FIRST_ID); + } finally { + await app.close(); + } + }); + + it('activates a registered Workspace by stable id', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'POST', + url: `/workspaces/${SECOND_ID}/activate`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + workspaceId: SECOND_ID, + active: true, + }); + expect(activationMocks.activateWorkspacePath).toHaveBeenCalledWith( + '/tmp/second', + ); + expect(storageMocks.resetStorageCache).toHaveBeenCalledOnce(); + expect( + preprocessingMocks.resetPreprocessDispatcher, + ).toHaveBeenCalledOnce(); + } finally { + await app.close(); + } + }); + + it('renames a Workspace and refreshes active metadata', async () => { + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'PATCH', + url: `/workspaces/${FIRST_ID}`, + payload: { name: 'Primary' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + workspaceId: FIRST_ID, + name: 'Primary', + active: true, + }); + expect(testState.active?.name).toBe('Primary'); + } finally { + await app.close(); + } + }); + + it('unregisters an inactive Workspace but protects the active one', async () => { + const app = await buildApp(); + try { + const activeResponse = await app.inject({ + method: 'DELETE', + url: `/workspaces/${FIRST_ID}`, + }); + expect(activeResponse.statusCode).toBe(409); + + const inactiveResponse = await app.inject({ + method: 'DELETE', + url: `/workspaces/${SECOND_ID}`, + }); + expect(inactiveResponse.statusCode).toBe(204); + expect(repository.get(SECOND_ID)).toBeNull(); + } finally { + await app.close(); + } + }); + + it('keeps managed collections readable but hides paths and rejects mutations', async () => { + testState.managed = true; + const app = await buildApp(); + try { + const list = await app.inject({ method: 'GET', url: '/workspaces' }); + expect(list.statusCode).toBe(200); + expect(list.json()).toEqual([ + { + workspaceId: FIRST_ID, + name: 'First', + path: null, + active: true, + }, + { + workspaceId: SECOND_ID, + name: 'Second', + path: null, + active: false, + }, + ]); + + const create = await app.inject({ + method: 'POST', + url: '/workspaces', + payload: { path: '/tmp/new' }, + }); + expect(create.statusCode).toBe(403); + } finally { + await app.close(); + } + }); + + it('validates ids and reports unknown Workspaces', async () => { + const app = await buildApp(); + try { + const invalid = await app.inject({ + method: 'GET', + url: '/workspaces/not-a-uuid', + }); + expect(invalid.statusCode).toBe(400); + + const missing = await app.inject({ + method: 'GET', + url: '/workspaces/00000000-0000-4000-8000-000000000099', + }); + expect(missing.statusCode).toBe(404); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts new file mode 100644 index 000000000..ae2f67e4b --- /dev/null +++ b/apps/server/src/modules/workspaces.route.ts @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { z } from 'zod'; + +import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; + +import { resetPreprocessDispatcher } from './preprocessing/index.js'; +import { getStructuredStore, resetStorageCache } from './storage/index.js'; +import { + activateWorkspacePath, + prepareWorkspacePath, + WorkspaceActivationInProgressError, + WorkspaceActivationTimeoutError, +} from './workspace-activation.js'; +import { + getWorkspaceHandle, + isManagedMode, + resolveWorkspacePath, + updateActiveWorkspaceHandle, +} from './workspace.js'; + +import type { WorkspaceHandle } from './storage/index.js'; +import type { ApiErrorBody, WorkspaceDescriptor } from '@huabu/shared'; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; + +const workspaceIdSchema = z.string().uuid(); + +function sendError( + reply: FastifyReply, + status: number, + message: string, + code?: string, + details?: unknown, +): FastifyReply { + const body: ApiErrorBody = { + message, + ...(code ? { code } : {}), + ...(details !== undefined ? { details } : {}), + }; + return reply.status(status).send(body); +} + +function isLocalhost(ip: string): boolean { + return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; +} + +function rejectReadOnlyMutation( + request: FastifyRequest, + reply: FastifyReply, +): FastifyReply | null { + if (isManagedMode()) { + return sendError(reply, 403, 'Workspace collection is read-only'); + } + if (!isLocalhost(request.ip)) { + return sendError( + reply, + 403, + 'Forbidden: workspace settings can only be changed from localhost', + ); + } + return null; +} + +function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { + const active = getWorkspaceHandle()?.workspaceId === workspace.workspaceId; + return { + workspaceId: workspace.workspaceId, + name: workspace.name, + path: isManagedMode() ? null : workspace.workspacePath, + active, + }; +} + +function parseWorkspaceId( + rawWorkspaceId: string, + reply: FastifyReply, +): string | FastifyReply { + const parsed = workspaceIdSchema.safeParse(rawWorkspaceId); + if (!parsed.success) { + return sendError(reply, 400, 'Invalid Workspace id'); + } + return parsed.data; +} + +function sendPreparationError( + reply: FastifyReply, + error: unknown, +): FastifyReply { + if (error instanceof WorkspaceActivationTimeoutError) { + return sendError( + reply, + 504, + error.message, + 'WORKSPACE_ACTIVATION_TIMEOUT', + { seconds: error.timeoutSeconds }, + ); + } + if (error instanceof WorkspaceActivationInProgressError) { + return sendError( + reply, + 409, + error.message, + 'WORKSPACE_ACTIVATION_IN_PROGRESS', + ); + } + return sendError(reply, 400, (error as Error).message); +} + +interface WorkspaceParams { + workspaceId: string; +} + +const workspacesRoutes: FastifyPluginAsync = async (app) => { + app.get('/', async () => + getStructuredStore().workspaces().list().map(descriptor), + ); + + app.post('/', async (request, reply) => { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + + const parsed = workspaceCreateSchema.safeParse(request.body); + if (!parsed.success) { + return sendError( + reply, + 400, + parsed.error.issues[0]?.message ?? 'Invalid request body', + ); + } + + try { + const workspacePath = resolveWorkspacePath(parsed.data.path); + const repository = getStructuredStore().workspaces(); + const existing = repository.getByPath(workspacePath); + if (existing) { + const workspace = parsed.data.name + ? (repository.rename(existing.workspaceId, parsed.data.name) ?? + existing) + : existing; + updateActiveWorkspaceHandle(workspace); + return reply.send(descriptor(workspace)); + } + + await prepareWorkspacePath(workspacePath); + let workspace = repository.open(workspacePath); + if (parsed.data.name) { + workspace = + repository.rename(workspace.workspaceId, parsed.data.name) ?? + workspace; + } + return reply.status(201).send(descriptor(workspace)); + } catch (error) { + return sendPreparationError(reply, error); + } + }); + + app.get<{ Params: WorkspaceParams }>( + '/:workspaceId', + async (request, reply) => { + const parsedId = parseWorkspaceId(request.params.workspaceId, reply); + if (typeof parsedId !== 'string') return parsedId; + const workspace = getStructuredStore().workspaces().get(parsedId); + if (!workspace) return sendError(reply, 404, 'Workspace not found'); + return reply.send(descriptor(workspace)); + }, + ); + + app.post<{ Params: WorkspaceParams }>( + '/:workspaceId/activate', + async (request, reply) => { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + const parsedId = parseWorkspaceId(request.params.workspaceId, reply); + if (typeof parsedId !== 'string') return parsedId; + + const workspace = getStructuredStore().workspaces().get(parsedId); + if (!workspace) return sendError(reply, 404, 'Workspace not found'); + try { + await activateWorkspacePath(workspace.workspacePath); + resetStorageCache(); + resetPreprocessDispatcher(); + return reply.send(descriptor(getWorkspaceHandle() ?? workspace)); + } catch (error) { + return sendPreparationError(reply, error); + } + }, + ); + + app.patch<{ Params: WorkspaceParams }>( + '/:workspaceId', + async (request, reply) => { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + const parsedId = parseWorkspaceId(request.params.workspaceId, reply); + if (typeof parsedId !== 'string') return parsedId; + const parsed = workspaceRenameSchema.safeParse(request.body); + if (!parsed.success) { + return sendError( + reply, + 400, + parsed.error.issues[0]?.message ?? 'Invalid request body', + ); + } + + try { + const workspace = getStructuredStore() + .workspaces() + .rename(parsedId, parsed.data.name); + if (!workspace) return sendError(reply, 404, 'Workspace not found'); + updateActiveWorkspaceHandle(workspace); + return reply.send(descriptor(workspace)); + } catch (error) { + return sendError(reply, 400, (error as Error).message); + } + }, + ); + + app.delete<{ Params: WorkspaceParams }>( + '/:workspaceId', + async (request, reply) => { + const rejected = rejectReadOnlyMutation(request, reply); + if (rejected) return rejected; + const parsedId = parseWorkspaceId(request.params.workspaceId, reply); + if (typeof parsedId !== 'string') return parsedId; + if (getWorkspaceHandle()?.workspaceId === parsedId) { + return sendError(reply, 409, 'Cannot unregister the active Workspace'); + } + if (!getStructuredStore().workspaces().remove(parsedId)) { + return sendError(reply, 404, 'Workspace not found'); + } + return reply.status(204).send(); + }, + ); +}; + +export default workspacesRoutes; diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 1ff77b5a5..627f64816 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -21,6 +21,10 @@ export const routes = { workspace: '/workspace', workspacePickFolder: '/workspace/pick-folder', workspaceValidatePath: '/workspace/validate-path', + workspaces: '/workspaces', + workspaceById: (workspaceId: string) => `/workspaces/${enc(workspaceId)}`, + workspaceActivate: (workspaceId: string) => + `/workspaces/${enc(workspaceId)}/activate`, // ── LLM ─────────────────────────────────────────────────────────── llmConfig: '/llm/config', diff --git a/packages/shared/src/types/api/workspace.ts b/packages/shared/src/types/api/workspace.ts index 6f33e4148..efeb439a4 100644 --- a/packages/shared/src/types/api/workspace.ts +++ b/packages/shared/src/types/api/workspace.ts @@ -44,6 +44,29 @@ export const workspaceInfoSchema = z.object({ }); export type WorkspaceInfo = z.infer; +/** One Workspace exposed by the plural management API. */ +export const workspaceDescriptorSchema = z.object({ + workspaceId: z.string().uuid(), + name: z.string().min(1), + /** Disk path in free mode; hidden for managed deployments. */ + path: z.string().nullable(), + active: z.boolean(), +}); +export type WorkspaceDescriptor = z.infer; + +/** Body for `POST /api/workspaces`. */ +export const workspaceCreateSchema = z.object({ + path: z.string().min(1, 'Workspace path is required'), + name: z.string().trim().min(1, 'Workspace name is required').optional(), +}); +export type WorkspaceCreateRequest = z.infer; + +/** Body for `PATCH /api/workspaces/:workspaceId`. */ +export const workspaceRenameSchema = z.object({ + name: z.string().trim().min(1, 'Workspace name is required'), +}); +export type WorkspaceRenameRequest = z.infer; + /** * Result of `POST /api/workspace/pick-folder`. * From 50c6bf83bcad6fc51eee92bd4d43018266a9839b Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Sat, 22 Aug 2026 12:00:50 +0800 Subject: [PATCH 3/9] feat(storage): persist workspace registry --- .../storage/backends/disk/structured-store.ts | 16 +- .../disk/workspace-repository.test.ts | 103 +++++++- .../backends/disk/workspace-repository.ts | 244 ++++++++++++++++-- apps/server/src/modules/storage/storage.ts | 3 +- apps/server/vitest.config.ts | 1 + apps/server/vitest.setup.ts | 24 ++ docs/architecture/canvas-storage.md | 10 +- 7 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 apps/server/vitest.setup.ts diff --git a/apps/server/src/modules/storage/backends/disk/structured-store.ts b/apps/server/src/modules/storage/backends/disk/structured-store.ts index 4d63af98a..c6ef82549 100644 --- a/apps/server/src/modules/storage/backends/disk/structured-store.ts +++ b/apps/server/src/modules/storage/backends/disk/structured-store.ts @@ -21,6 +21,8 @@ * Space has one long-lived instance. */ +import path from 'node:path'; + import { getCanvasStore } from './legacy/canvas-store-cache.js'; import { createDiskSpaceLogs } from './space-logs.js'; import { DiskSpaceNodes } from './space-nodes.js'; @@ -28,14 +30,24 @@ import { createDiskSpaceRecordReader } from './space-record.js'; import { DiskSpaceRepository } from './space-repository.js'; import { DiskSpaceTasks } from './space-tasks.js'; import { createDiskSpaceWrite } from './space-write.js'; -import { DiskWorkspaceRepository } from './workspace-repository.js'; +import { + DiskWorkspaceRepository, + WORKSPACE_REGISTRY_FILENAME, +} from './workspace-repository.js'; import type { StorageHealth } from '../../ports/common.js'; import type { SpaceHandle, StructuredStore } from '../../ports/structured.js'; export class DiskStructuredStore implements StructuredStore { readonly kind = 'disk' as const; - readonly #workspaces = new DiskWorkspaceRepository(); + readonly #workspaces: DiskWorkspaceRepository; + + constructor(dataDir?: string) { + const registryFilePath = dataDir + ? path.join(dataDir, 'storage', 'disk', WORKSPACE_REGISTRY_FILENAME) + : undefined; + this.#workspaces = new DiskWorkspaceRepository(registryFilePath); + } async init(): Promise { // The workspace directory is prepared by `workspace-prepare.ts`; Space diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index 8446b7ad7..fa642ee4c 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -5,16 +5,19 @@ import { mkdirSync, mkdtempSync, readFileSync, + renameSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { DiskStructuredStore } from './structured-store.js'; import { DiskWorkspaceRepository, WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME, + WORKSPACE_REGISTRY_FILENAME, } from './workspace-repository.js'; describe('DiskWorkspaceRepository', () => { @@ -30,6 +33,10 @@ describe('DiskWorkspaceRepository', () => { return path.join(root, WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME); } + function registryPath(dataDir: string): string { + return path.join(dataDir, 'storage', 'disk', WORKSPACE_REGISTRY_FILENAME); + } + afterAll(() => { for (const root of roots) { rmSync(root, { recursive: true, force: true }); @@ -72,10 +79,77 @@ describe('DiskWorkspaceRepository', () => { expect(repository.list()).toEqual([first, second]); }); + it('persists only the stable id-to-path index and rehydrates metadata after restart', () => { + const dataDir = tempDir('huabu-workspace-data-'); + const root = tempDir('huabu-workspace-persisted-'); + const filePath = registryPath(dataDir); + const repository = new DiskWorkspaceRepository(filePath); + const workspace = repository.open(root); + const renamed = repository.rename(workspace.workspaceId, 'Research'); + + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ + schemaVersion: 1, + workspaces: [ + { + workspaceId: workspace.workspaceId, + workspacePath: path.resolve(root), + }, + ], + }); + expect(new DiskWorkspaceRepository(filePath).list()).toEqual([renamed]); + }); + + it('stores the production registry under the Disk backend data directory', () => { + const dataDir = tempDir('huabu-workspace-store-data-'); + const root = tempDir('huabu-workspace-store-root-'); + const workspace = new DiskStructuredStore(dataDir).workspaces().open(root); + + expect(JSON.parse(readFileSync(registryPath(dataDir), 'utf8'))).toEqual({ + schemaVersion: 1, + workspaces: [ + { + workspaceId: workspace.workspaceId, + workspacePath: path.resolve(root), + }, + ], + }); + }); + + it('recognizes an externally moved Workspace by id and replaces its registered path', () => { + const dataDir = tempDir('huabu-workspace-move-data-'); + const parent = tempDir('huabu-workspace-move-root-'); + const originalPath = path.join(parent, 'original'); + const movedPath = path.join(parent, 'moved'); + mkdirSync(originalPath); + const filePath = registryPath(dataDir); + const original = new DiskWorkspaceRepository(filePath).open(originalPath); + + renameSync(originalPath, movedPath); + const reopened = new DiskWorkspaceRepository(filePath); + const moved = reopened.open(movedPath); + + expect(moved).toEqual({ + ...original, + workspacePath: path.resolve(movedPath), + }); + expect(reopened.getByPath(originalPath)).toBeNull(); + expect(reopened.get(original.workspaceId)).toEqual(moved); + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ + schemaVersion: 1, + workspaces: [ + { + workspaceId: original.workspaceId, + workspacePath: path.resolve(movedPath), + }, + ], + }); + }); + it('rejects two different paths that claim the same Workspace identity', () => { const firstRoot = tempDir('huabu-workspace-original-'); const secondRoot = tempDir('huabu-workspace-copy-'); - const repository = new DiskWorkspaceRepository(); + const filePath = registryPath(tempDir('huabu-workspace-copy-data-')); + const repository = new DiskWorkspaceRepository(filePath); const first = repository.open(firstRoot); mkdirSync(path.dirname(manifestPath(secondRoot)), { recursive: true }); @@ -89,9 +163,9 @@ describe('DiskWorkspaceRepository', () => { 'utf8', ); - expect(() => repository.open(secondRoot)).toThrow( - /same Workspace identity.*different paths/i, - ); + expect(() => + new DiskWorkspaceRepository(filePath).open(secondRoot), + ).toThrow(/present at both.*copied Workspaces/i); }); it('rejects a malformed existing manifest instead of replacing it', () => { @@ -122,7 +196,8 @@ describe('DiskWorkspaceRepository', () => { it('unregisters a Workspace without deleting its manifest', () => { const root = tempDir('huabu-workspace-remove-'); - const repository = new DiskWorkspaceRepository(); + const filePath = registryPath(tempDir('huabu-workspace-remove-data-')); + const repository = new DiskWorkspaceRepository(filePath); const workspace = repository.open(root); expect(repository.remove(workspace.workspaceId)).toBe(true); @@ -131,5 +206,23 @@ describe('DiskWorkspaceRepository', () => { expect(readFileSync(manifestPath(root), 'utf8')).toContain( workspace.workspaceId, ); + expect(new DiskWorkspaceRepository(filePath).list()).toEqual([]); + }); + + it('rejects a malformed durable registry instead of discarding it', () => { + const filePath = registryPath(tempDir('huabu-workspace-corrupt-data-')); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync( + filePath, + JSON.stringify({ + schemaVersion: 1, + workspaces: [{ workspacePath: '/missing-id', name: 'Not indexed' }], + }), + 'utf8', + ); + + expect(() => new DiskWorkspaceRepository(filePath).list()).toThrow( + /workspace registry.*invalid/i, + ); }); }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 00090b609..f02d3dfa1 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -4,11 +4,14 @@ /** * Disk implementation of the Workspace storage port. * - * A Workspace owns one stable id in `/.huabu/workspace.json`. - * Existing Home folders predate that manifest, so opening one adopts it by - * creating the file once. The repository then indexes immutable handles by - * both id and canonical path; it never treats a copied manifest as two - * Workspaces with the same identity. + * A Workspace owns one stable id and display name in + * `/.huabu/workspace.json`. Existing Home folders predate that + * manifest, so opening one adopts it by creating the file once. + * + * The Server data directory holds a separate discovery index containing only + * `workspaceId -> workspacePath`. That deliberate duplication is the minimum + * needed to recognize an externally moved Workspace after restart; all other + * metadata remains authoritative in the Workspace-owned manifest. */ import { randomUUID } from 'node:crypto'; @@ -31,7 +34,9 @@ import type { export const WORKSPACE_MANIFEST_DIR = '.huabu'; export const WORKSPACE_MANIFEST_FILENAME = 'workspace.json'; +export const WORKSPACE_REGISTRY_FILENAME = 'workspaces.json'; const WORKSPACE_MANIFEST_SCHEMA_VERSION = 1; +const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1; const workspaceManifestSchema = z.object({ schemaVersion: z.literal(WORKSPACE_MANIFEST_SCHEMA_VERSION), @@ -39,7 +44,29 @@ const workspaceManifestSchema = z.object({ name: z.string().trim().min(1), }); +const workspaceRegistrySchema = z + .object({ + schemaVersion: z.literal(WORKSPACE_REGISTRY_SCHEMA_VERSION), + workspaces: z.array( + z + .object({ + workspaceId: z.string().uuid(), + workspacePath: z + .string() + .min(1) + .refine((value) => path.isAbsolute(value), { + message: 'Workspace registry paths must be absolute', + }), + }) + .strict(), + ), + }) + .strict(); + export type WorkspaceManifest = z.infer; +type WorkspaceRegistryEntry = z.infer< + typeof workspaceRegistrySchema +>['workspaces'][number]; function manifestPath(workspacePath: string): string { return path.join( @@ -53,11 +80,19 @@ function defaultWorkspaceName(workspacePath: string): string { return path.basename(workspacePath) || 'Workspace'; } -function readManifest(filePath: string): WorkspaceManifest { +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + +function readManifestFile( + filePath: string, + allowMissing: boolean, +): WorkspaceManifest | null { let parsed: unknown; try { parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; } catch (error) { + if (allowMissing && isMissing(error)) return null; throw new Error( `Workspace manifest at ${filePath} could not be read: ${(error as Error).message}`, ); @@ -72,6 +107,51 @@ function readManifest(filePath: string): WorkspaceManifest { return result.data; } +function readManifest(filePath: string): WorkspaceManifest { + return readManifestFile(filePath, false) as WorkspaceManifest; +} + +function readWorkspaceRegistry(filePath: string): WorkspaceRegistryEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; + } catch (error) { + if (isMissing(error)) return []; + throw new Error( + `Workspace registry at ${filePath} could not be read: ${(error as Error).message}`, + ); + } + + const result = workspaceRegistrySchema.safeParse(parsed); + if (!result.success) { + throw new Error( + `Workspace registry at ${filePath} is invalid: ${result.error.issues[0]?.message ?? 'invalid registry'}`, + ); + } + + const entries = result.data.workspaces.map((entry) => ({ + workspaceId: entry.workspaceId, + workspacePath: path.resolve(entry.workspacePath), + })); + const ids = new Set(); + const paths = new Set(); + for (const entry of entries) { + if (ids.has(entry.workspaceId)) { + throw new Error( + `Workspace registry at ${filePath} contains duplicate id ${entry.workspaceId}`, + ); + } + if (paths.has(entry.workspacePath)) { + throw new Error( + `Workspace registry at ${filePath} contains duplicate path ${entry.workspacePath}`, + ); + } + ids.add(entry.workspaceId); + paths.add(entry.workspacePath); + } + return entries; +} + function isAlreadyExists(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'; } @@ -107,8 +187,94 @@ export function ensureWorkspaceManifestOnDisk( export class DiskWorkspaceRepository implements WorkspaceRepository { readonly #byId = new Map(); readonly #byPath = new Map(); + readonly #registryFilePath: string | null; + readonly #registeredPathById = new Map(); + readonly #registeredIdByPath = new Map(); + #registryLoaded = false; + + constructor(registryFilePath?: string) { + this.#registryFilePath = registryFilePath + ? path.resolve(registryFilePath) + : null; + } + + #ensureRegistryLoaded(): void { + if (this.#registryLoaded) return; + const entries = this.#registryFilePath + ? readWorkspaceRegistry(this.#registryFilePath) + : []; + this.#replaceRegistrationMaps(entries); + this.#registryLoaded = true; + } + + #replaceRegistrationMaps(entries: readonly WorkspaceRegistryEntry[]): void { + this.#registeredPathById.clear(); + this.#registeredIdByPath.clear(); + for (const entry of entries) { + this.#registeredPathById.set(entry.workspaceId, entry.workspacePath); + this.#registeredIdByPath.set(entry.workspacePath, entry.workspaceId); + } + } + + #registrationEntries(): WorkspaceRegistryEntry[] { + return [...this.#registeredPathById].map( + ([workspaceId, workspacePath]) => ({ workspaceId, workspacePath }), + ); + } + + #commitRegistrations(entries: readonly WorkspaceRegistryEntry[]): void { + if (this.#registryFilePath) { + atomicWriteJson(this.#registryFilePath, { + schemaVersion: WORKSPACE_REGISTRY_SCHEMA_VERSION, + workspaces: entries, + }); + } + this.#replaceRegistrationMaps(entries); + } + + #upsertRegistration(workspaceId: string, workspacePath: string): void { + let replaced = false; + const entries = this.#registrationEntries().map((entry) => { + if (entry.workspaceId !== workspaceId) return entry; + replaced = true; + return { workspaceId, workspacePath }; + }); + if (!replaced) entries.push({ workspaceId, workspacePath }); + this.#commitRegistrations(entries); + } + + #hydrateRegistered( + workspaceId: string, + workspacePath: string, + ): WorkspaceHandle { + const existing = this.#byId.get(workspaceId); + if (existing) return existing; + + const manifest = readManifest(manifestPath(workspacePath)); + if (manifest.workspaceId !== workspaceId) { + throw new Error( + `Workspace registry maps ${workspaceId} to ${workspacePath}, but that path claims ${manifest.workspaceId}`, + ); + } + const existingAtPath = this.#byPath.get(workspacePath); + if (existingAtPath && existingAtPath.workspaceId !== workspaceId) { + throw new Error( + `Workspace path ${workspacePath} is already open as ${existingAtPath.workspaceId}`, + ); + } + + const handle: WorkspaceHandle = Object.freeze({ + workspaceId, + workspacePath, + name: manifest.name, + }); + this.#byId.set(workspaceId, handle); + this.#byPath.set(workspacePath, handle); + return handle; + } open(rawWorkspacePath: string): WorkspaceHandle { + this.#ensureRegistryLoaded(); const workspacePath = path.resolve(rawWorkspacePath); const manifest = ensureWorkspaceManifestOnDisk(workspacePath); const existingAtPath = this.#byPath.get(workspacePath); @@ -121,37 +287,78 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { return existingAtPath; } - const existingWithId = this.#byId.get(manifest.workspaceId); - if (existingWithId && existingWithId.workspacePath !== workspacePath) { + const registeredIdAtPath = this.#registeredIdByPath.get(workspacePath); + if (registeredIdAtPath && registeredIdAtPath !== manifest.workspaceId) { throw new Error( - `The same Workspace identity ${manifest.workspaceId} was opened from different paths: ${existingWithId.workspacePath} and ${workspacePath}`, + `Workspace registry maps ${workspacePath} to ${registeredIdAtPath}, but that path claims ${manifest.workspaceId}`, ); } + const existingWithId = this.#byId.get(manifest.workspaceId); + const previousPath = + this.#registeredPathById.get(manifest.workspaceId) ?? + existingWithId?.workspacePath; + if (previousPath && previousPath !== workspacePath) { + const previousManifest = readManifestFile( + manifestPath(previousPath), + true, + ); + if (previousManifest?.workspaceId === manifest.workspaceId) { + throw new Error( + `Workspace identity ${manifest.workspaceId} is present at both ${previousPath} and ${workspacePath}; copied Workspaces must receive distinct identities`, + ); + } + if (previousManifest) { + throw new Error( + `Workspace registry maps ${manifest.workspaceId} to ${previousPath}, but that path now claims ${previousManifest.workspaceId}`, + ); + } + } + const handle: WorkspaceHandle = Object.freeze({ workspaceId: manifest.workspaceId, workspacePath, name: manifest.name, }); + this.#upsertRegistration(handle.workspaceId, handle.workspacePath); + if (previousPath && previousPath !== workspacePath) { + this.#byPath.delete(previousPath); + } this.#byId.set(handle.workspaceId, handle); this.#byPath.set(handle.workspacePath, handle); return handle; } get(workspaceId: string): WorkspaceHandle | null { - return this.#byId.get(workspaceId) ?? null; + this.#ensureRegistryLoaded(); + const existing = this.#byId.get(workspaceId); + if (existing) return existing; + const workspacePath = this.#registeredPathById.get(workspaceId); + return workspacePath + ? this.#hydrateRegistered(workspaceId, workspacePath) + : null; } getByPath(rawWorkspacePath: string): WorkspaceHandle | null { - return this.#byPath.get(path.resolve(rawWorkspacePath)) ?? null; + this.#ensureRegistryLoaded(); + const workspacePath = path.resolve(rawWorkspacePath); + const existing = this.#byPath.get(workspacePath); + if (existing) return existing; + const workspaceId = this.#registeredIdByPath.get(workspacePath); + return workspaceId + ? this.#hydrateRegistered(workspaceId, workspacePath) + : null; } list(): readonly WorkspaceHandle[] { - return [...this.#byId.values()]; + this.#ensureRegistryLoaded(); + return this.#registrationEntries().map((entry) => + this.#hydrateRegistered(entry.workspaceId, entry.workspacePath), + ); } rename(workspaceId: string, rawName: string): WorkspaceHandle | null { - const current = this.#byId.get(workspaceId); + const current = this.get(workspaceId); if (!current) return null; const name = rawName.trim(); @@ -172,10 +379,15 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { } remove(workspaceId: string): boolean { - const current = this.#byId.get(workspaceId); - if (!current) return false; + this.#ensureRegistryLoaded(); + const workspacePath = this.#registeredPathById.get(workspaceId); + if (!workspacePath) return false; + const entries = this.#registrationEntries().filter( + (entry) => entry.workspaceId !== workspaceId, + ); + this.#commitRegistrations(entries); this.#byId.delete(workspaceId); - this.#byPath.delete(current.workspacePath); + this.#byPath.delete(workspacePath); return true; } } diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index 3c9a67bb6..cad4433af 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -23,6 +23,7 @@ import path from 'node:path'; +import { getDataDir } from '../../data-dir.js'; import { acquireWorkspaceOperationLease, getWorkspacePath, @@ -114,7 +115,7 @@ function buildBlobStore(profile: StorageProfile): BlobStore { function buildStructuredStore(profile: StorageProfile): StructuredStore { switch (profile.structured.kind) { case 'disk': - return new DiskStructuredStore(); + return new DiskStructuredStore(getDataDir()); default: throw new Error( `Unsupported structured backend: ${profile.structured.kind}`, diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index bd95f9fe6..017321d88 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ test: { environment: 'node', globals: true, + setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.ts', 'evals/**/*.test.ts'], // Many suites drive real filesystem work through temp workspaces, which // overruns the 5s default once the whole repo runs in parallel. diff --git a/apps/server/vitest.setup.ts b/apps/server/vitest.setup.ts new file mode 100644 index 000000000..38335ff8b --- /dev/null +++ b/apps/server/vitest.setup.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterAll } from 'vitest'; + +/** + * Persistent Server stores must never write into the repository during tests. + * Vitest executes setup files inside each isolated test-file environment, so + * every file receives its own real data directory without sharing registries + * with parallel suites. + */ +const previousDataDir = process.env.HUABU_DATA_DIR; +const testDataDir = mkdtempSync(path.join(tmpdir(), 'huabu-server-test-data-')); +process.env.HUABU_DATA_DIR = testDataDir; + +afterAll(() => { + if (previousDataDir === undefined) delete process.env.HUABU_DATA_DIR; + else process.env.HUABU_DATA_DIR = previousDataDir; + rmSync(testDataDir, { recursive: true, force: true }); +}); diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 7b098ef4a..f6865d6af 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -1,6 +1,6 @@ # Canvas Storage Architecture -> Last updated: 2026-08-18 +> Last updated: 2026-08-22 ## 1. Overview @@ -11,6 +11,10 @@ Runtime Home-folder activation prepares and migrates the selected directory in a ## 2. Disk Layout ``` +/ + storage/disk/ + workspaces.json # durable workspaceId -> absolute path index + / .huabu/ workspace.json # stable Workspace identity + display name @@ -42,6 +46,7 @@ Runtime Home-folder activation prepares and migrates the selected directory in a Key points: +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. Identity and display metadata remain authoritative in each Workspace's own `.huabu/workspace.json`. Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id; two live paths carrying the same id are rejected as a copied-identity conflict. Unregistering removes only the index entry and never deletes the Workspace directory or manifest. - An ordinary Space **directory name** is derived from its title via `toSafeFilename(title)`, not from `canvasId`. The stable `canvasId` only lives inside `space.json`; the World is the reserved `.world` exception. - `SpaceRepository.list()` rescans on every call, returns ordinary Spaces only, skips ordinary directories without `space.json`, rejects malformed records (including a corrupt established World), and leaves ordering to the caller. `worldId()` resolves the hidden World from the same rescan and rejects missing or malformed state; it is the single World resolution point the collection's own create/delete/rename refusals also go through. - The `canvasId -> directory name` index in `canvas-dirs.ts` is invalidated **lazily**, never by a live filesystem watcher. Catalogue reads and the World resolvers re-scan unconditionally, server-owned create/rename register the new directory directly, and `CanvasStore.read()` re-scans and retries when `space.json` is missing — which is also how a Finder-side Space rename is adopted as the new title. A stale index therefore self-heals on the next read of the affected Space. @@ -69,6 +74,7 @@ Key points: | Path | Responsibility | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ports/blob.ts` | Backend-neutral `BlobStore` connection/scope contract for opaque bytes and bounded materialization leases. | +| `ports/workspace.ts` | Backend-neutral Workspace identity, membership, and locator repository. | | `ports/structured.ts` | Backend-neutral `StructuredStore`, the `SpaceRepository` collection, and the `SpaceHandle` composite: record read/ordered write, nodes, changes, Tasks, and events. | | `ports/contracts/` | Reusable Space-collection, node, Space-write, log, blob, and store suites; guarantees are the minimum every adapter implements. | | `backends/disk/` | Disk implementations plus before-image restoration for rejected in-process ordered batches; no journal or startup recovery. | @@ -81,7 +87,7 @@ Key points: The Disk structured adapter and compatibility facade resolve the same cached legacy object, so migration does not create two in-memory authorities. All portable repository methods are async. `SpaceRepository` owns membership reads, structured create/rename, and an exclusive `beginDelete()` session; composition holds that session across the existing blob-first delete saga and then calls `finish()` or `abort()`. Every Space-record write goes through `SpaceHandle.write`, which is the version-checked replacement with the node and delta batch attached; `SpaceHandle.read` reads only. `SpaceNodes` returns complete records plus revision tokens without exposing filenames. `write` preserves the old node mutations → Space record → optional delta order. When a normal in-process node → record → delta batch rejects, the adapter must restore that batch's prestate before returning the rejection. An explicit title rename remains the preceding ordered, best-effort boundary and is not rolled back with the batch. The port does not promise process-crash or power-loss recovery, a determinate result after an unknown remote outcome, multi-process serialization, idempotent retry, or publication. Disk meets the in-process restoration requirement with its existing before-image rollback; a SQL adapter may use a native transaction. -Canvas persistence DTOs and the write coordinator live under `modules/canvas/`; physical Workspace paths, name indexes, directory-handle arbitration, and boot migrations live under `modules/workspace/`; generic filesystem and Markdown codecs live under `utils/`. `canvasRoot()` validates the identifier and then verifies that the resolved Space directory remains a strict descendant of the active Workspace before any downstream Disk operation receives it. `module-boundaries.test.ts` enforces the storage dependency direction and prevents new consumers of the forwarding shims. +Canvas persistence DTOs and the write coordinator live under `modules/canvas/`; Workspace identity, durable membership, and Disk locators live under `modules/storage/`, while active-Workspace lifecycle and boot migrations remain under `modules/workspace/`; generic filesystem and Markdown codecs live under `utils/`. `canvasRoot()` validates the identifier and then verifies that the resolved Space directory remains a strict descendant of the active Workspace before any downstream Disk operation receives it. `module-boundaries.test.ts` enforces the storage dependency direction and prevents new consumers of the forwarding shims. Space deletion is serialized against composed blob puts by a writer-preferring admission coordinator and holds an active-Workspace lease across blob cleanup and structured destruction. `beginDelete()` acquires the exclusive session before blob I/O; `finish()` removes structured state, while `abort()` releases the fence without doing so. Blobs are swept before structure so a failed sweep can be retried while the Space record still names them. Puts already admitted may finish; a put queued behind a successful deletion rechecks existence and fails without recreating blobs, while a failed blob sweep leaves the record available for retry. Mutations through existing Space handles and repositories reject while deletion is active or queued; reads remain available for cleanup. Residual direct-filesystem capabilities such as ZIP import, RFS upload/delete, and external-note claim are outside this repository fence and remain blockers for a non-Disk profile. From 8cf6258c3de48d966ff8bb2d55736e03a08e075a Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 11:58:03 +0800 Subject: [PATCH 4/9] fix(workspace): make the durable registry recoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR --- .../storage/backends/disk/structured-store.ts | 18 -- .../disk/workspace-repository.test.ts | 98 +++++- .../backends/disk/workspace-repository.ts | 297 +++++++++--------- apps/server/src/modules/storage/index.ts | 8 + .../src/modules/storage/ports/structured.ts | 3 - .../src/modules/storage/profile.test.ts | 17 - apps/server/src/modules/storage/storage.ts | 53 +++- apps/server/src/modules/workspace-prepare.ts | 7 +- .../src/modules/workspace.route.test.ts | 2 - apps/server/src/modules/workspace.test.ts | 76 ++++- apps/server/src/modules/workspace.ts | 47 +-- .../src/modules/workspaces.route.test.ts | 19 +- apps/server/src/modules/workspaces.route.ts | 68 ++-- apps/web/src/api/_routes.ts | 4 - docs/architecture/canvas-storage.md | 6 +- 15 files changed, 446 insertions(+), 277 deletions(-) diff --git a/apps/server/src/modules/storage/backends/disk/structured-store.ts b/apps/server/src/modules/storage/backends/disk/structured-store.ts index c6ef82549..e02f25d49 100644 --- a/apps/server/src/modules/storage/backends/disk/structured-store.ts +++ b/apps/server/src/modules/storage/backends/disk/structured-store.ts @@ -21,8 +21,6 @@ * Space has one long-lived instance. */ -import path from 'node:path'; - import { getCanvasStore } from './legacy/canvas-store-cache.js'; import { createDiskSpaceLogs } from './space-logs.js'; import { DiskSpaceNodes } from './space-nodes.js'; @@ -30,24 +28,12 @@ import { createDiskSpaceRecordReader } from './space-record.js'; import { DiskSpaceRepository } from './space-repository.js'; import { DiskSpaceTasks } from './space-tasks.js'; import { createDiskSpaceWrite } from './space-write.js'; -import { - DiskWorkspaceRepository, - WORKSPACE_REGISTRY_FILENAME, -} from './workspace-repository.js'; import type { StorageHealth } from '../../ports/common.js'; import type { SpaceHandle, StructuredStore } from '../../ports/structured.js'; export class DiskStructuredStore implements StructuredStore { readonly kind = 'disk' as const; - readonly #workspaces: DiskWorkspaceRepository; - - constructor(dataDir?: string) { - const registryFilePath = dataDir - ? path.join(dataDir, 'storage', 'disk', WORKSPACE_REGISTRY_FILENAME) - : undefined; - this.#workspaces = new DiskWorkspaceRepository(registryFilePath); - } async init(): Promise { // The workspace directory is prepared by `workspace-prepare.ts`; Space @@ -60,10 +46,6 @@ export class DiskStructuredStore implements StructuredStore { async close(): Promise {} - workspaces(): DiskWorkspaceRepository { - return this.#workspaces; - } - spaces(): DiskSpaceRepository { return new DiskSpaceRepository(); } diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index fa642ee4c..52dce02c3 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -12,13 +12,13 @@ import { import { tmpdir } from 'node:os'; import path from 'node:path'; -import { DiskStructuredStore } from './structured-store.js'; import { DiskWorkspaceRepository, WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME, WORKSPACE_REGISTRY_FILENAME, } from './workspace-repository.js'; +import { getWorkspaceRepository } from '../../storage.js'; describe('DiskWorkspaceRepository', () => { const roots: string[] = []; @@ -100,9 +100,10 @@ describe('DiskWorkspaceRepository', () => { }); it('stores the production registry under the Disk backend data directory', () => { - const dataDir = tempDir('huabu-workspace-store-data-'); + // vitest.setup.ts points HUABU_DATA_DIR at a per-file temp directory. + const dataDir = process.env.HUABU_DATA_DIR as string; const root = tempDir('huabu-workspace-store-root-'); - const workspace = new DiskStructuredStore(dataDir).workspaces().open(root); + const workspace = getWorkspaceRepository().open(root); expect(JSON.parse(readFileSync(registryPath(dataDir), 'utf8'))).toEqual({ schemaVersion: 1, @@ -209,6 +210,97 @@ describe('DiskWorkspaceRepository', () => { expect(new DiskWorkspaceRepository(filePath).list()).toEqual([]); }); + it('keeps the collection readable when one registered folder is gone', () => { + const dataDir = tempDir('huabu-workspace-gone-data-'); + const kept = tempDir('huabu-workspace-gone-kept-'); + const gone = tempDir('huabu-workspace-gone-missing-'); + const filePath = registryPath(dataDir); + const repository = new DiskWorkspaceRepository(filePath); + const survivor = repository.open(kept); + const missing = repository.open(gone); + + // An unplugged volume or a folder deleted in Finder looks exactly like + // this, and it must not take down the whole listing. + rmSync(gone, { recursive: true, force: true }); + + const reopened = new DiskWorkspaceRepository(filePath); + expect(reopened.list()).toEqual([survivor]); + expect(reopened.get(missing.workspaceId)).toBeNull(); + expect(reopened.getByPath(gone)).toBeNull(); + // The registration survives, so the Workspace returns when its volume does. + expect( + ( + JSON.parse(readFileSync(filePath, 'utf8')) as { + workspaces: unknown[]; + } + ).workspaces, + ).toHaveLength(2); + // ... and it can still be unregistered while unreachable. + expect(reopened.remove(missing.workspaceId)).toBe(true); + expect(reopened.list()).toEqual([survivor]); + }); + + it('still reports a malformed manifest rather than hiding it as unreachable', () => { + const filePath = registryPath(tempDir('huabu-workspace-damaged-data-')); + const root = tempDir('huabu-workspace-damaged-'); + const repository = new DiskWorkspaceRepository(filePath); + repository.open(root); + writeFileSync(manifestPath(root), '{ definitely not json', 'utf8'); + + expect(() => new DiskWorkspaceRepository(filePath).list()).toThrow( + /workspace manifest/i, + ); + }); + + it('re-adopts a registered path whose folder was replaced', () => { + const dataDir = tempDir('huabu-workspace-replaced-data-'); + const parent = tempDir('huabu-workspace-replaced-root-'); + const root = path.join(parent, 'home'); + mkdirSync(root); + const filePath = registryPath(dataDir); + const repository = new DiskWorkspaceRepository(filePath); + const original = repository.open(root); + + // Deleted outside Huabu and recreated by hand: the folder at this path is + // a different Workspace now, and pointing Huabu at it must keep working. + rmSync(root, { recursive: true, force: true }); + mkdirSync(root); + + const sameProcess = repository.open(root); + expect(sameProcess.workspaceId).not.toBe(original.workspaceId); + expect(sameProcess.workspacePath).toBe(path.resolve(root)); + expect(repository.get(original.workspaceId)).toBeNull(); + expect(repository.list()).toEqual([sameProcess]); + + // And the same holds for a Server that only sees it after a restart. + rmSync(root, { recursive: true, force: true }); + mkdirSync(root); + const afterRestart = new DiskWorkspaceRepository(filePath); + const readopted = afterRestart.open(root); + expect(readopted.workspaceId).not.toBe(sameProcess.workspaceId); + expect(afterRestart.list()).toEqual([readopted]); + }); + + it('frees a moved Workspace to keep its identity when its old path is reused', () => { + const dataDir = tempDir('huabu-workspace-swap-data-'); + const parent = tempDir('huabu-workspace-swap-root-'); + const original = path.join(parent, 'original'); + const moved = path.join(parent, 'moved'); + mkdirSync(original); + const filePath = registryPath(dataDir); + const repository = new DiskWorkspaceRepository(filePath); + const first = repository.open(original); + + renameSync(original, moved); + mkdirSync(original); + const replacement = repository.open(original); + const relocated = repository.open(moved); + + expect(relocated.workspaceId).toBe(first.workspaceId); + expect(relocated.workspacePath).toBe(path.resolve(moved)); + expect(repository.list()).toEqual([replacement, relocated]); + }); + it('rejects a malformed durable registry instead of discarding it', () => { const filePath = registryPath(tempDir('huabu-workspace-corrupt-data-')); mkdirSync(path.dirname(filePath), { recursive: true }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index f02d3dfa1..deaceb793 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -11,7 +11,14 @@ * The Server data directory holds a separate discovery index containing only * `workspaceId -> workspacePath`. That deliberate duplication is the minimum * needed to recognize an externally moved Workspace after restart; all other - * metadata remains authoritative in the Workspace-owned manifest. + * metadata remains authoritative in the Workspace-owned manifest and is read + * back from it on demand rather than cached here. + * + * The index is therefore the single in-process representation of membership, + * and it is re-read from disk on every access. Reads cost a few small JSON + * files for a collection that holds a handful of entries, and in exchange a + * registry edited by another process — or by hand — can never be silently + * truncated by a stale in-memory copy. */ import { randomUUID } from 'node:crypto'; @@ -26,6 +33,7 @@ import path from 'node:path'; import { z } from 'zod'; import { atomicWriteJson } from '../../../../utils/fs.js'; +import { getLogger } from '../../../../utils/logger.js'; import type { WorkspaceHandle, @@ -38,6 +46,8 @@ export const WORKSPACE_REGISTRY_FILENAME = 'workspaces.json'; const WORKSPACE_MANIFEST_SCHEMA_VERSION = 1; const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1; +const log = getLogger('workspace-repository'); + const workspaceManifestSchema = z.object({ schemaVersion: z.literal(WORKSPACE_MANIFEST_SCHEMA_VERSION), workspaceId: z.string().uuid(), @@ -68,6 +78,11 @@ type WorkspaceRegistryEntry = z.infer< typeof workspaceRegistrySchema >['workspaces'][number]; +/** Where the Disk backend keeps its discovery index inside the data dir. */ +export function workspaceRegistryPath(dataDir: string): string { + return path.join(dataDir, 'storage', 'disk', WORKSPACE_REGISTRY_FILENAME); +} + function manifestPath(workspacePath: string): string { return path.join( workspacePath, @@ -80,19 +95,28 @@ function defaultWorkspaceName(workspacePath: string): string { return path.basename(workspacePath) || 'Workspace'; } -function isMissing(error: unknown): boolean { - return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +/** + * Whether an error means "this path does not resolve right now". + * + * A deleted folder and an unmounted volume both land here, and both describe + * a Workspace that is temporarily unreachable rather than a corrupt one. A + * malformed manifest is deliberately *not* in this set: that is damage the + * operator has to see, so it keeps throwing. + */ +function isUnreachable(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === 'ENOENT' || code === 'ENOTDIR'; } function readManifestFile( filePath: string, - allowMissing: boolean, + allowUnreachable: boolean, ): WorkspaceManifest | null { let parsed: unknown; try { parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; } catch (error) { - if (allowMissing && isMissing(error)) return null; + if (allowUnreachable && isUnreachable(error)) return null; throw new Error( `Workspace manifest at ${filePath} could not be read: ${(error as Error).message}`, ); @@ -116,7 +140,7 @@ function readWorkspaceRegistry(filePath: string): WorkspaceRegistryEntry[] { try { parsed = JSON.parse(readFileSync(filePath, 'utf8')) as unknown; } catch (error) { - if (isMissing(error)) return []; + if (isUnreachable(error)) return []; throw new Error( `Workspace registry at ${filePath} could not be read: ${(error as Error).message}`, ); @@ -156,10 +180,40 @@ function isAlreadyExists(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'; } +function sameEntries( + left: readonly WorkspaceRegistryEntry[], + right: readonly WorkspaceRegistryEntry[], +): boolean { + return ( + left.length === right.length && + left.every( + (entry, index) => + entry.workspaceId === right[index]?.workspaceId && + entry.workspacePath === right[index]?.workspacePath, + ) + ); +} + +function toHandle( + manifest: WorkspaceManifest, + workspacePath: string, +): WorkspaceHandle { + return Object.freeze({ + workspaceId: manifest.workspaceId, + workspacePath, + name: manifest.name, + }); +} + /** * Return the persisted Workspace identity, adopting a legacy folder when the * manifest is absent. `wx` keeps concurrent adopters from overwriting the * winner; every contender then reads the same durable identity. + * + * Deliberately separate from the repository: workspace preparation runs this + * inside the isolated child process, where creating the manifest is part of + * the blocking filesystem work being contained, while registry membership + * stays a Server-process decision with exactly one writer. */ export function ensureWorkspaceManifestOnDisk( rawWorkspacePath: string, @@ -185,12 +239,13 @@ export function ensureWorkspaceManifestOnDisk( } export class DiskWorkspaceRepository implements WorkspaceRepository { - readonly #byId = new Map(); - readonly #byPath = new Map(); readonly #registryFilePath: string | null; - readonly #registeredPathById = new Map(); - readonly #registeredIdByPath = new Map(); - #registryLoaded = false; + /** + * Membership for a repository with no durable file behind it — the shape + * tests and scripts build. When a registry path is configured this stays + * unused and the file is the only copy. + */ + #memory: WorkspaceRegistryEntry[] = []; constructor(registryFilePath?: string) { this.#registryFilePath = registryFilePath @@ -198,171 +253,138 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { : null; } - #ensureRegistryLoaded(): void { - if (this.#registryLoaded) return; - const entries = this.#registryFilePath + #read(): WorkspaceRegistryEntry[] { + return this.#registryFilePath ? readWorkspaceRegistry(this.#registryFilePath) - : []; - this.#replaceRegistrationMaps(entries); - this.#registryLoaded = true; - } - - #replaceRegistrationMaps(entries: readonly WorkspaceRegistryEntry[]): void { - this.#registeredPathById.clear(); - this.#registeredIdByPath.clear(); - for (const entry of entries) { - this.#registeredPathById.set(entry.workspaceId, entry.workspacePath); - this.#registeredIdByPath.set(entry.workspacePath, entry.workspaceId); - } - } - - #registrationEntries(): WorkspaceRegistryEntry[] { - return [...this.#registeredPathById].map( - ([workspaceId, workspacePath]) => ({ workspaceId, workspacePath }), - ); + : [...this.#memory]; } - #commitRegistrations(entries: readonly WorkspaceRegistryEntry[]): void { + #write(entries: readonly WorkspaceRegistryEntry[]): void { if (this.#registryFilePath) { atomicWriteJson(this.#registryFilePath, { schemaVersion: WORKSPACE_REGISTRY_SCHEMA_VERSION, workspaces: entries, }); + return; } - this.#replaceRegistrationMaps(entries); + this.#memory = [...entries]; } - #upsertRegistration(workspaceId: string, workspacePath: string): void { - let replaced = false; - const entries = this.#registrationEntries().map((entry) => { - if (entry.workspaceId !== workspaceId) return entry; - replaced = true; - return { workspaceId, workspacePath }; - }); - if (!replaced) entries.push({ workspaceId, workspacePath }); - this.#commitRegistrations(entries); - } - - #hydrateRegistered( - workspaceId: string, - workspacePath: string, - ): WorkspaceHandle { - const existing = this.#byId.get(workspaceId); - if (existing) return existing; - - const manifest = readManifest(manifestPath(workspacePath)); - if (manifest.workspaceId !== workspaceId) { - throw new Error( - `Workspace registry maps ${workspaceId} to ${workspacePath}, but that path claims ${manifest.workspaceId}`, + /** + * Read one member's live metadata, or null when it cannot answer for + * itself. + * + * Two registrations are stale rather than fatal: a Workspace whose folder + * is gone or unmounted, and a path that some other Workspace has since + * taken over. Both resolve to "not a member right now" so one unplugged + * drive cannot take down the whole collection; `open()` repairs the index + * when the path is opened again. + */ + #hydrate(entry: WorkspaceRegistryEntry): WorkspaceHandle | null { + const manifest = readManifestFile(manifestPath(entry.workspacePath), true); + if (!manifest) { + log.warn( + { workspaceId: entry.workspaceId, workspacePath: entry.workspacePath }, + 'Registered Workspace is not reachable; skipping', ); + return null; } - const existingAtPath = this.#byPath.get(workspacePath); - if (existingAtPath && existingAtPath.workspaceId !== workspaceId) { - throw new Error( - `Workspace path ${workspacePath} is already open as ${existingAtPath.workspaceId}`, + if (manifest.workspaceId !== entry.workspaceId) { + log.warn( + { + workspaceId: entry.workspaceId, + workspacePath: entry.workspacePath, + claimedBy: manifest.workspaceId, + }, + 'Registered Workspace path now belongs to a different Workspace; skipping', ); + return null; } - - const handle: WorkspaceHandle = Object.freeze({ - workspaceId, - workspacePath, - name: manifest.name, - }); - this.#byId.set(workspaceId, handle); - this.#byPath.set(workspacePath, handle); - return handle; + return toHandle(manifest, entry.workspacePath); } open(rawWorkspacePath: string): WorkspaceHandle { - this.#ensureRegistryLoaded(); const workspacePath = path.resolve(rawWorkspacePath); const manifest = ensureWorkspaceManifestOnDisk(workspacePath); - const existingAtPath = this.#byPath.get(workspacePath); - if (existingAtPath) { - if (existingAtPath.workspaceId !== manifest.workspaceId) { - throw new Error( - `Workspace identity at ${workspacePath} changed from ${existingAtPath.workspaceId} to ${manifest.workspaceId}`, - ); - } - return existingAtPath; - } - - const registeredIdAtPath = this.#registeredIdByPath.get(workspacePath); - if (registeredIdAtPath && registeredIdAtPath !== manifest.workspaceId) { - throw new Error( - `Workspace registry maps ${workspacePath} to ${registeredIdAtPath}, but that path claims ${manifest.workspaceId}`, - ); - } - - const existingWithId = this.#byId.get(manifest.workspaceId); - const previousPath = - this.#registeredPathById.get(manifest.workspaceId) ?? - existingWithId?.workspacePath; - if (previousPath && previousPath !== workspacePath) { - const previousManifest = readManifestFile( - manifestPath(previousPath), + const entries = this.#read(); + + // The same identity registered at another path is either a Workspace that + // moved — the old path no longer answers to it — or a copy, which must be + // refused so two live directories cannot share one identity. + const elsewhere = entries.find( + (entry) => + entry.workspaceId === manifest.workspaceId && + entry.workspacePath !== workspacePath, + ); + if (elsewhere) { + const previous = readManifestFile( + manifestPath(elsewhere.workspacePath), true, ); - if (previousManifest?.workspaceId === manifest.workspaceId) { - throw new Error( - `Workspace identity ${manifest.workspaceId} is present at both ${previousPath} and ${workspacePath}; copied Workspaces must receive distinct identities`, - ); - } - if (previousManifest) { + if (previous?.workspaceId === manifest.workspaceId) { throw new Error( - `Workspace registry maps ${manifest.workspaceId} to ${previousPath}, but that path now claims ${previousManifest.workspaceId}`, + `Workspace identity ${manifest.workspaceId} is present at both ${elsewhere.workspacePath} and ${workspacePath}; copied Workspaces must receive distinct identities`, ); } } - const handle: WorkspaceHandle = Object.freeze({ + // Drop any registration that named this path for a different Workspace: + // the directory was replaced, so the manifest now on disk is the truth. + const surviving = entries.filter( + (entry) => + entry.workspaceId === manifest.workspaceId || + entry.workspacePath !== workspacePath, + ); + const replacement: WorkspaceRegistryEntry = { workspaceId: manifest.workspaceId, workspacePath, - name: manifest.name, + }; + let replaced = false; + const next = surviving.map((entry) => { + if (entry.workspaceId !== manifest.workspaceId) return entry; + replaced = true; + return replacement; }); - this.#upsertRegistration(handle.workspaceId, handle.workspacePath); - if (previousPath && previousPath !== workspacePath) { - this.#byPath.delete(previousPath); - } - this.#byId.set(handle.workspaceId, handle); - this.#byPath.set(handle.workspacePath, handle); - return handle; + if (!replaced) next.push(replacement); + + if (!sameEntries(entries, next)) this.#write(next); + return toHandle(manifest, workspacePath); } get(workspaceId: string): WorkspaceHandle | null { - this.#ensureRegistryLoaded(); - const existing = this.#byId.get(workspaceId); - if (existing) return existing; - const workspacePath = this.#registeredPathById.get(workspaceId); - return workspacePath - ? this.#hydrateRegistered(workspaceId, workspacePath) - : null; + const entry = this.#read().find( + (candidate) => candidate.workspaceId === workspaceId, + ); + return entry ? this.#hydrate(entry) : null; } getByPath(rawWorkspacePath: string): WorkspaceHandle | null { - this.#ensureRegistryLoaded(); const workspacePath = path.resolve(rawWorkspacePath); - const existing = this.#byPath.get(workspacePath); - if (existing) return existing; - const workspaceId = this.#registeredIdByPath.get(workspacePath); - return workspaceId - ? this.#hydrateRegistered(workspaceId, workspacePath) - : null; + const entry = this.#read().find( + (candidate) => candidate.workspacePath === workspacePath, + ); + return entry ? this.#hydrate(entry) : null; } list(): readonly WorkspaceHandle[] { - this.#ensureRegistryLoaded(); - return this.#registrationEntries().map((entry) => - this.#hydrateRegistered(entry.workspaceId, entry.workspacePath), - ); + const handles: WorkspaceHandle[] = []; + for (const entry of this.#read()) { + const handle = this.#hydrate(entry); + if (handle) handles.push(handle); + } + return handles; } rename(workspaceId: string, rawName: string): WorkspaceHandle | null { const current = this.get(workspaceId); if (!current) return null; + // Guards the manifest's own schema, not the route body: a repository + // caller that trims to nothing would otherwise write a file that fails + // validation on the next read. const name = rawName.trim(); if (!name) throw new Error('Workspace name is required'); + const filePath = manifestPath(current.workspacePath); const manifest = readManifest(filePath); if (manifest.workspaceId !== workspaceId) { @@ -371,23 +393,14 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { ); } atomicWriteJson(filePath, { ...manifest, name }); - - const updated: WorkspaceHandle = Object.freeze({ ...current, name }); - this.#byId.set(workspaceId, updated); - this.#byPath.set(current.workspacePath, updated); - return updated; + return toHandle({ ...manifest, name }, current.workspacePath); } remove(workspaceId: string): boolean { - this.#ensureRegistryLoaded(); - const workspacePath = this.#registeredPathById.get(workspaceId); - if (!workspacePath) return false; - const entries = this.#registrationEntries().filter( - (entry) => entry.workspaceId !== workspaceId, - ); - this.#commitRegistrations(entries); - this.#byId.delete(workspaceId); - this.#byPath.delete(workspacePath); + const entries = this.#read(); + const next = entries.filter((entry) => entry.workspaceId !== workspaceId); + if (next.length === entries.length) return false; + this.#write(next); return true; } } diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index c9050beb8..8c6a6f760 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -46,6 +46,13 @@ export { } from './backends/disk/space-dir-handles.js'; export type { SpaceDirHandleOwner } from './backends/disk/space-dir-handles.js'; export { ensureWorldCanvasOnDisk } from './backends/disk/world-canvas.js'; +/** + * Workspace adoption, split out from the repository on purpose: the isolated + * preparation child creates the manifest as part of the blocking filesystem + * work it exists to contain, while registry membership stays a Server-process + * decision with exactly one writer. + */ +export { ensureWorkspaceManifestOnDisk } from './backends/disk/workspace-repository.js'; export { withCanvasMutex, updateNode } from '../canvas/write-coordinator.js'; export type { UpdateNodeOptions, @@ -68,6 +75,7 @@ export { getBlobStore, getStorage, getStructuredStore, + getWorkspaceRepository, initStorage, setStorageForTesting, spaceDirectory, diff --git a/apps/server/src/modules/storage/ports/structured.ts b/apps/server/src/modules/storage/ports/structured.ts index 6bb0b2985..274074304 100644 --- a/apps/server/src/modules/storage/ports/structured.ts +++ b/apps/server/src/modules/storage/ports/structured.ts @@ -37,7 +37,6 @@ */ import type { StorageHealth } from './common.js'; -import type { WorkspaceRepository } from './workspace.js'; import type { CanvasEvent, CanvasFile, @@ -70,8 +69,6 @@ export interface StructuredStore { init(): Promise; health(): Promise; close(): Promise; - /** Return the repository for Workspace membership and identity. */ - workspaces(): WorkspaceRepository; /** * Return a repository for the currently-bound Space collection. * diff --git a/apps/server/src/modules/storage/profile.test.ts b/apps/server/src/modules/storage/profile.test.ts index addf226a6..18be15c91 100644 --- a/apps/server/src/modules/storage/profile.test.ts +++ b/apps/server/src/modules/storage/profile.test.ts @@ -9,7 +9,6 @@ import { StorageProfileError, validateStorageProfile, } from './profile.js'; -import { createStorage, initStorage, setStorageForTesting } from './storage.js'; describe('parseStorageProfile', () => { it('defaults both axes to disk', () => { @@ -100,19 +99,3 @@ describe('requiresExplicitInit', () => { expect(requiresExplicitInit(profile)).toBe(true); }); }); - -describe('storage initialization', () => { - it('keeps adapters first used during managed Workspace bootstrap', async () => { - const profile = { - structured: { kind: 'disk' as const }, - blobs: { kind: 'disk' as const }, - }; - const storage = createStorage(profile); - const restore = setStorageForTesting(storage); - try { - expect(await initStorage(profile)).toBe(storage); - } finally { - restore(); - } - }); -}); diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index cad4433af..1b900da35 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -31,6 +31,10 @@ import { import { DiskBlobStore } from './backends/disk/blob-store.js'; import { canvasRoot } from './backends/disk/layout.js'; import { DiskStructuredStore } from './backends/disk/structured-store.js'; +import { + DiskWorkspaceRepository, + workspaceRegistryPath, +} from './backends/disk/workspace-repository.js'; import { parseStorageProfile, requiresExplicitInit, @@ -54,6 +58,7 @@ import type { SpaceDeleteFinishResult, StructuredStore, } from './ports/structured.js'; +import type { WorkspaceRepository } from './ports/workspace.js'; import type { Readable } from 'node:stream'; /** @@ -115,7 +120,7 @@ function buildBlobStore(profile: StorageProfile): BlobStore { function buildStructuredStore(profile: StorageProfile): StructuredStore { switch (profile.structured.kind) { case 'disk': - return new DiskStructuredStore(getDataDir()); + return new DiskStructuredStore(); default: throw new Error( `Unsupported structured backend: ${profile.structured.kind}`, @@ -136,8 +141,44 @@ export function createStorage(profile: StorageProfile): Storage { // ─── Process-wide holder ──────────────────────────────────────────────────── let current: Storage | null = null; +let workspaces: WorkspaceRepository | null = null; let spaceCreateTail: Promise = Promise.resolve(); +/** + * The Workspace repository for the configured structured backend. + * + * Workspace identity is a *precondition* of storage rather than a product of + * it: every Disk adapter resolves its paths against the active Workspace, and + * managed mode has to adopt its Workspace while `app.ts` is still evaluating — + * before the boot sequence can await {@link initStorage}. Routing it through + * {@link getStructuredStore} would therefore drag the whole composition open + * on the on-demand path, which that path explicitly refuses for a backend with + * connections to hold. + * + * So the composition root owns this axis separately. It still maps a backend + * kind to exactly one adapter, and it holds one instance for the process. A + * future structured backend whose Workspace membership lives in a connection + * has to answer the boot-order question here — by making Workspace adoption + * part of the awaited startup sequence — rather than by widening the + * on-demand path. + */ +export function getWorkspaceRepository(): WorkspaceRepository { + if (workspaces) return workspaces; + + const profile = parseStorageProfile(); + switch (profile.structured.kind) { + case 'disk': + workspaces = new DiskWorkspaceRepository( + workspaceRegistryPath(getDataDir()), + ); + return workspaces; + default: + throw new StorageProfileError( + `Workspace membership is not implemented for the "${profile.structured.kind}" structured backend.`, + ); + } +} + function defaultSpaceTitle( existing: readonly { readonly title: string | null }[], ): string { @@ -187,15 +228,7 @@ function ensure(): Storage { export async function initStorage( profile: StorageProfile = parseStorageProfile(), ): Promise { - const storage = current ?? createStorage(profile); - if ( - storage.profile.structured.kind !== profile.structured.kind || - storage.profile.blobs.kind !== profile.blobs.kind - ) { - throw new StorageProfileError( - 'Storage was initialized with a different profile than the adapters already in use.', - ); - } + const storage = createStorage(profile); await Promise.all([storage.structured.init(), storage.blobs.init()]); current = storage; return storage; diff --git a/apps/server/src/modules/workspace-prepare.ts b/apps/server/src/modules/workspace-prepare.ts index c7a9e5838..387335cf8 100644 --- a/apps/server/src/modules/workspace-prepare.ts +++ b/apps/server/src/modules/workspace-prepare.ts @@ -12,8 +12,8 @@ import { mkdirSync } from 'node:fs'; import { + ensureWorkspaceManifestOnDisk, ensureWorldCanvasOnDisk, - getStructuredStore, } from './storage/index.js'; import { migrateLegacyAcpSessions } from './workspace/migrations/migrate-acp-sessions.js'; import { @@ -36,7 +36,10 @@ export function prepareWorkspaceOnDisk(workspacePath: string): void { mkdirSync(workspacePath, { recursive: true }); // Adopt Home folders created by older Huabu versions before any other // migration runs. Managed and free mode therefore share one identity path. - getStructuredStore().workspaces().open(workspacePath); + // Only the manifest is written here: this runs inside the isolated + // preparation child, and registry membership is the Server process's call + // so the durable index keeps exactly one writer. + ensureWorkspaceManifestOnDisk(workspacePath); // Demo-stage rename: canvas.json -> space.json, .memory/canvas.md -> // .memory/space.md, setting/.huabu.md -> setting/user.md. Runs first so // later readers / migrations see the new names. DELETE-ME later. diff --git a/apps/server/src/modules/workspace.route.test.ts b/apps/server/src/modules/workspace.route.test.ts index bd253e516..6158642ae 100644 --- a/apps/server/src/modules/workspace.route.test.ts +++ b/apps/server/src/modules/workspace.route.test.ts @@ -24,8 +24,6 @@ vi.mock('./workspace.js', async (importOriginal) => { name: workspaceState.name, } : null, - getWorkspaceName: () => - workspaceState.configured ? workspaceState.name : null, getWorkspacePath: () => workspaceState.path, isManagedMode: () => workspaceState.managed, isWorkspaceConfigured: () => workspaceState.configured, diff --git a/apps/server/src/modules/workspace.test.ts b/apps/server/src/modules/workspace.test.ts index 623824c64..0bcfe9273 100644 --- a/apps/server/src/modules/workspace.test.ts +++ b/apps/server/src/modules/workspace.test.ts @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { getWorkspaceRepository } from './storage/index.js'; +import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { acquireWorkspaceOperationLease, commitWorkspacePath, @@ -14,21 +16,21 @@ import { WorkspaceOperationInProgressError, } from './workspace.js'; -describe('workspace operation leases', () => { - const roots: string[] = []; +const roots: string[] = []; - function tempDir(prefix: string): string { - const dir = mkdtempSync(path.join(tmpdir(), prefix)); - roots.push(dir); - return dir; - } +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(tmpdir(), prefix)); + roots.push(dir); + return dir; +} - afterAll(() => { - for (const root of roots) { - rmSync(root, { recursive: true, force: true }); - } - }); +afterAll(() => { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } +}); +describe('workspace operation leases', () => { it('blocks a commit to another workspace until every lease is released', () => { const current = tempDir('huabu-workspace-current-'); const next = tempDir('huabu-workspace-next-'); @@ -77,6 +79,39 @@ describe('workspace operation leases', () => { expect(getWorkspacePath()).toBe(path.resolve(next)); }); + it('leaves a refused workspace untouched on disk and in the registry', () => { + const current = tempDir('huabu-workspace-held-'); + const refused = tempDir('huabu-workspace-refused-'); + setWorkspacePath(current); + const lease = acquireWorkspaceOperationLease(); + + try { + // The guard has to run before any adoption: a switch this process + // refuses must not leave the target carrying a manifest or a + // registration it never asked for. + expect(() => commitWorkspacePath(refused)).toThrow( + WorkspaceOperationInProgressError, + ); + expect(existsSync(path.join(refused, '.huabu', 'workspace.json'))).toBe( + false, + ); + expect( + getWorkspaceRepository() + .list() + .some((workspace) => workspace.workspacePath === refused), + ).toBe(false); + const registry = path.join( + process.env.HUABU_DATA_DIR as string, + 'storage', + 'disk', + 'workspaces.json', + ); + expect(readFileSync(registry, 'utf8')).not.toContain(refused); + } finally { + lease.release(); + } + }); + it('keeps the active path and manifest identity in one Workspace handle', () => { const current = tempDir('huabu-workspace-handle-'); setWorkspacePath(current); @@ -91,3 +126,18 @@ describe('workspace operation leases', () => { ); }); }); + +describe('workspace preparation', () => { + it('adopts the manifest without claiming registry membership', () => { + const root = tempDir('huabu-workspace-prepared-'); + + prepareWorkspaceOnDisk(root); + + // Preparation runs inside a disposable child process. Creating the + // manifest belongs there — it is part of the blocking filesystem work + // being contained — but membership stays a Server-process decision so the + // durable registry keeps exactly one writer and no stale cache to lose. + expect(existsSync(path.join(root, '.huabu', 'workspace.json'))).toBe(true); + expect(getWorkspaceRepository().getByPath(root)).toBeNull(); + }); +}); diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index 33e22b648..b3420707b 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -41,7 +41,7 @@ import path from 'node:path'; import { resetExternalNoteSessions } from './canvas/external-watcher.js'; import { refreshCanvasDirIndex } from './storage/canvas-dirs.js'; -import { getStructuredStore } from './storage/index.js'; +import { getWorkspaceRepository } from './storage/index.js'; import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { invalidateUserSkill } from '../prompt/index.js'; @@ -51,7 +51,6 @@ const ENV_KEY = 'HUABU_WORKSPACE'; let _workspaceHandle: WorkspaceHandle | null = null; let _managed = false; -let _leasedWorkspaceId: string | null = null; let _leasedWorkspacePath: string | null = null; let _workspaceOperationLeaseCount = 0; @@ -63,7 +62,6 @@ let _workspaceOperationLeaseCount = 0; * original result. */ export interface WorkspaceOperationLease { - readonly workspaceId: string; readonly workspacePath: string; release(): void; } @@ -149,55 +147,32 @@ export function getWorkspaceHandle(): WorkspaceHandle | null { * same path remains allowed. */ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { - const workspace = getWorkspaceHandle(); - if (!workspace) { - throw new Error( - 'Workspace path has not been configured. ' + - 'Activate a workspace first (PUT /api/workspace) or set ' + - `${ENV_KEY} in the environment.`, - ); - } + const workspacePath = getWorkspacePath(); if ( _workspaceOperationLeaseCount > 0 && - (_leasedWorkspaceId !== workspace.workspaceId || - _leasedWorkspacePath !== workspace.workspacePath) + _leasedWorkspacePath !== workspacePath ) { throw new Error('Workspace operation lease invariant violated'); } - _leasedWorkspaceId = workspace.workspaceId; - _leasedWorkspacePath = workspace.workspacePath; + _leasedWorkspacePath = workspacePath; _workspaceOperationLeaseCount += 1; let released = false; return Object.freeze({ - workspaceId: workspace.workspaceId, - workspacePath: workspace.workspacePath, + workspacePath, release(): void { if (released) return; released = true; _workspaceOperationLeaseCount -= 1; if (_workspaceOperationLeaseCount === 0) { - _leasedWorkspaceId = null; _leasedWorkspacePath = null; } }, }); } -/** - * Display label for the currently-active workspace. In managed mode this - * is the basename of the locked path; in free mode it's also the basename - * of the user-picked path. Returns `null` if nothing is active yet. - * - * Never reveals the full host path — safe to send to the client even when - * the deployment treats the host filesystem as private. - */ -export function getWorkspaceName(): string | null { - return _workspaceHandle?.name ?? null; -} - /** * (Free mode) Activate any absolute path as the current workspace and * create the workspace folder. Rejected in managed mode — the workspace @@ -231,14 +206,18 @@ export function resolveWorkspacePath(newPath: string): string { * has completed successfully. Opening the handle reads the prepared manifest; * the compatibility fallback creates it when an older caller committed a * legacy path without going through preparation first. + * + * The lease guard runs *before* that, so a switch this process must refuse + * cannot leave the target adopted or registered on its way out. */ -export function commitWorkspacePath(resolvedPath: string): void { - commitWorkspaceHandle(getStructuredStore().workspaces().open(resolvedPath)); +export function commitWorkspacePath(rawPath: string): void { + const resolvedPath = path.resolve(rawPath); + assertWorkspacePathChangeAllowed(resolvedPath); + commitWorkspaceHandle(getWorkspaceRepository().open(resolvedPath)); } /** Commit an already-prepared Workspace handle to process-local state. */ -export function commitWorkspaceHandle(workspace: WorkspaceHandle): void { - assertWorkspacePathChangeAllowed(workspace.workspacePath); +function commitWorkspaceHandle(workspace: WorkspaceHandle): void { _workspaceHandle = workspace; // Drop the cached canvas-dir index so subsequent lookups (used by // migrations and route handlers) reflect the new workspace. diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts index 2dd0257c9..e0d153411 100644 --- a/apps/server/src/modules/workspaces.route.test.ts +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -76,7 +76,7 @@ const repository = vi.hoisted(() => ({ })); vi.mock('./storage/index.js', () => ({ - getStructuredStore: () => ({ workspaces: () => repository }), + getWorkspaceRepository: () => repository, resetStorageCache: storageMocks.resetStorageCache, })); @@ -254,10 +254,13 @@ describe('plural Workspace management routes', () => { } }); - it('keeps managed collections readable but hides paths and rejects mutations', async () => { + it('narrows a managed deployment to its own Workspace and rejects mutations', async () => { testState.managed = true; const app = await buildApp(); try { + // Registrations left in the data directory by a free-mode session are + // unaddressable here, so listing them would leak host folder names + // through the very API that redacts host paths. const list = await app.inject({ method: 'GET', url: '/workspaces' }); expect(list.statusCode).toBe(200); expect(list.json()).toEqual([ @@ -267,14 +270,14 @@ describe('plural Workspace management routes', () => { path: null, active: true, }, - { - workspaceId: SECOND_ID, - name: 'Second', - path: null, - active: false, - }, ]); + const other = await app.inject({ + method: 'GET', + url: `/workspaces/${SECOND_ID}`, + }); + expect(other.statusCode).toBe(404); + const create = await app.inject({ method: 'POST', url: '/workspaces', diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index ae2f67e4b..302ceb038 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; import { resetPreprocessDispatcher } from './preprocessing/index.js'; -import { getStructuredStore, resetStorageCache } from './storage/index.js'; +import { getWorkspaceRepository, resetStorageCache } from './storage/index.js'; import { activateWorkspacePath, prepareWorkspacePath, @@ -21,7 +21,12 @@ import { } from './workspace.js'; import type { WorkspaceHandle } from './storage/index.js'; -import type { ApiErrorBody, WorkspaceDescriptor } from '@huabu/shared'; +import type { + ApiErrorBody, + WorkspaceCreateRequest, + WorkspaceDescriptor, + WorkspaceRenameRequest, +} from '@huabu/shared'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; const workspaceIdSchema = z.string().uuid(); @@ -72,6 +77,28 @@ function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { }; } +/** + * The Workspaces this deployment may talk about. + * + * Managed mode locks its Workspace at boot, so every other registration in the + * data directory — a free-mode session that used the same one, say — is + * unaddressable here: activation is refused and paths are redacted. Listing + * those would leak nothing but the host folder names of Workspaces this + * deployment cannot reach, which is the very thing path redaction exists to + * prevent. Managed mode therefore sees exactly one Workspace: the active one. + */ +function visibleWorkspaces(): readonly WorkspaceHandle[] { + if (!isManagedMode()) return getWorkspaceRepository().list(); + const active = getWorkspaceHandle(); + return active ? [active] : []; +} + +function findVisible(workspaceId: string): WorkspaceHandle | null { + if (!isManagedMode()) return getWorkspaceRepository().get(workspaceId); + const active = getWorkspaceHandle(); + return active?.workspaceId === workspaceId ? active : null; +} + function parseWorkspaceId( rawWorkspaceId: string, reply: FastifyReply, @@ -112,11 +139,9 @@ interface WorkspaceParams { } const workspacesRoutes: FastifyPluginAsync = async (app) => { - app.get('/', async () => - getStructuredStore().workspaces().list().map(descriptor), - ); + app.get('/', async () => visibleWorkspaces().map(descriptor)); - app.post('/', async (request, reply) => { + app.post<{ Body: WorkspaceCreateRequest }>('/', async (request, reply) => { const rejected = rejectReadOnlyMutation(request, reply); if (rejected) return rejected; @@ -131,15 +156,14 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { try { const workspacePath = resolveWorkspacePath(parsed.data.path); - const repository = getStructuredStore().workspaces(); + const repository = getWorkspaceRepository(); const existing = repository.getByPath(workspacePath); if (existing) { - const workspace = parsed.data.name - ? (repository.rename(existing.workspaceId, parsed.data.name) ?? - existing) - : existing; - updateActiveWorkspaceHandle(workspace); - return reply.send(descriptor(workspace)); + if (!parsed.data.name) return reply.send(descriptor(existing)); + const renamed = + repository.rename(existing.workspaceId, parsed.data.name) ?? existing; + updateActiveWorkspaceHandle(renamed); + return reply.send(descriptor(renamed)); } await prepareWorkspacePath(workspacePath); @@ -160,7 +184,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { async (request, reply) => { const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; - const workspace = getStructuredStore().workspaces().get(parsedId); + const workspace = findVisible(parsedId); if (!workspace) return sendError(reply, 404, 'Workspace not found'); return reply.send(descriptor(workspace)); }, @@ -174,7 +198,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; - const workspace = getStructuredStore().workspaces().get(parsedId); + const workspace = getWorkspaceRepository().get(parsedId); if (!workspace) return sendError(reply, 404, 'Workspace not found'); try { await activateWorkspacePath(workspace.workspacePath); @@ -187,7 +211,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { }, ); - app.patch<{ Params: WorkspaceParams }>( + app.patch<{ Params: WorkspaceParams; Body: WorkspaceRenameRequest }>( '/:workspaceId', async (request, reply) => { const rejected = rejectReadOnlyMutation(request, reply); @@ -204,9 +228,10 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { } try { - const workspace = getStructuredStore() - .workspaces() - .rename(parsedId, parsed.data.name); + const workspace = getWorkspaceRepository().rename( + parsedId, + parsed.data.name, + ); if (!workspace) return sendError(reply, 404, 'Workspace not found'); updateActiveWorkspaceHandle(workspace); return reply.send(descriptor(workspace)); @@ -226,7 +251,10 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { if (getWorkspaceHandle()?.workspaceId === parsedId) { return sendError(reply, 409, 'Cannot unregister the active Workspace'); } - if (!getStructuredStore().workspaces().remove(parsedId)) { + // Deliberately not gated on the Workspace being readable: unregistering + // a folder that has since been deleted or unmounted is exactly when + // this is needed, and it only ever removes the index entry. + if (!getWorkspaceRepository().remove(parsedId)) { return sendError(reply, 404, 'Workspace not found'); } return reply.status(204).send(); diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 627f64816..1ff77b5a5 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -21,10 +21,6 @@ export const routes = { workspace: '/workspace', workspacePickFolder: '/workspace/pick-folder', workspaceValidatePath: '/workspace/validate-path', - workspaces: '/workspaces', - workspaceById: (workspaceId: string) => `/workspaces/${enc(workspaceId)}`, - workspaceActivate: (workspaceId: string) => - `/workspaces/${enc(workspaceId)}/activate`, // ── LLM ─────────────────────────────────────────────────────────── llmConfig: '/llm/config', diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index f6865d6af..8270afc42 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -46,7 +46,11 @@ Runtime Home-folder activation prepares and migrates the selected directory in a Key points: -- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. Identity and display metadata remain authoritative in each Workspace's own `.huabu/workspace.json`. Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id; two live paths carrying the same id are rejected as a copied-identity conflict. Unregistering removes only the index entry and never deletes the Workspace directory or manifest. +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `.huabu/workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. Two live paths carrying the same id are rejected as a copied-identity conflict; a path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. +- A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest. +- Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. Managed mode adopts its Workspace while `app.ts` is still evaluating, before the boot sequence can await `initStorage()`; 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. +- Managed deployments expose exactly one Workspace — the active one. 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. - An ordinary Space **directory name** is derived from its title via `toSafeFilename(title)`, not from `canvasId`. The stable `canvasId` only lives inside `space.json`; the World is the reserved `.world` exception. - `SpaceRepository.list()` rescans on every call, returns ordinary Spaces only, skips ordinary directories without `space.json`, rejects malformed records (including a corrupt established World), and leaves ordering to the caller. `worldId()` resolves the hidden World from the same rescan and rejects missing or malformed state; it is the single World resolution point the collection's own create/delete/rename refusals also go through. - The `canvasId -> directory name` index in `canvas-dirs.ts` is invalidated **lazily**, never by a live filesystem watcher. Catalogue reads and the World resolvers re-scan unconditionally, server-owned create/rename register the new directory directly, and `CanvasStore.read()` re-scans and retries when `space.json` is missing — which is also how a Finder-side Space rename is adopted as the new title. A stale index therefore self-heals on the next read of the affected Space. From 9671dd3469116a6ae679187138517923cef352a8 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 16:28:25 +0800 Subject: [PATCH 5/9] refactor(workspace): keep location out of the Workspace identity port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/.huabu/workspace.json` to `/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) Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR --- .../disk/workspace-repository.test.ts | 110 +++++++---- .../backends/disk/workspace-repository.ts | 175 +++++++++++------- apps/server/src/modules/storage/index.ts | 3 + .../src/modules/storage/ports/workspace.ts | 20 +- apps/server/src/modules/storage/storage.ts | 62 +++++-- .../src/modules/workspace-activation.test.ts | 4 +- .../src/modules/workspace-managed.test.ts | 4 +- .../src/modules/workspace.route.test.ts | 1 - apps/server/src/modules/workspace.route.ts | 10 +- apps/server/src/modules/workspace.test.ts | 30 ++- apps/server/src/modules/workspace.ts | 26 ++- .../src/modules/workspaces.route.test.ts | 95 ++++++---- apps/server/src/modules/workspaces.route.ts | 23 ++- docs/architecture/canvas-storage.md | 7 +- 14 files changed, 355 insertions(+), 215 deletions(-) diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index 52dce02c3..d85e2497f 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -14,11 +14,10 @@ import path from 'node:path'; import { DiskWorkspaceRepository, - WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME, WORKSPACE_REGISTRY_FILENAME, } from './workspace-repository.js'; -import { getWorkspaceRepository } from '../../storage.js'; +import { adoptWorkspaceDirectory } from '../../storage.js'; describe('DiskWorkspaceRepository', () => { const roots: string[] = []; @@ -30,7 +29,7 @@ describe('DiskWorkspaceRepository', () => { } function manifestPath(root: string): string { - return path.join(root, WORKSPACE_MANIFEST_DIR, WORKSPACE_MANIFEST_FILENAME); + return path.join(root, WORKSPACE_MANIFEST_FILENAME); } function registryPath(dataDir: string): string { @@ -43,12 +42,14 @@ describe('DiskWorkspaceRepository', () => { } }); - it('adopts a legacy Workspace by creating a stable hidden manifest', () => { + it('adopts a legacy Workspace by creating a stable manifest', () => { const root = tempDir('huabu-legacy-workspace-'); const firstRepository = new DiskWorkspaceRepository(); - const first = firstRepository.open(root); + const first = firstRepository.adopt(root); - expect(first.workspacePath).toBe(path.resolve(root)); + expect(firstRepository.directoryOf(first.workspaceId)).toBe( + path.resolve(root), + ); expect(first.name).toBe(path.basename(root)); expect(first.workspaceId).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, @@ -65,17 +66,21 @@ describe('DiskWorkspaceRepository', () => { name: path.basename(root), }); - const reopened = new DiskWorkspaceRepository().open(root); + const reopened = new DiskWorkspaceRepository().adopt(root); expect(reopened).toEqual(first); }); - it('indexes opened Workspaces by both stable id and canonical path', () => { + it('indexes adopted Workspaces by both stable id and canonical path', () => { const repository = new DiskWorkspaceRepository(); - const first = repository.open(tempDir('huabu-workspace-first-')); - const second = repository.open(tempDir('huabu-workspace-second-')); + const firstRoot = tempDir('huabu-workspace-first-'); + const first = repository.adopt(firstRoot); + const second = repository.adopt(tempDir('huabu-workspace-second-')); expect(repository.get(first.workspaceId)).toEqual(first); - expect(repository.getByPath(first.workspacePath)).toEqual(first); + expect(repository.at(firstRoot)).toEqual(first); + expect(repository.directoryOf(first.workspaceId)).toBe( + path.resolve(firstRoot), + ); expect(repository.list()).toEqual([first, second]); }); @@ -84,7 +89,7 @@ describe('DiskWorkspaceRepository', () => { const root = tempDir('huabu-workspace-persisted-'); const filePath = registryPath(dataDir); const repository = new DiskWorkspaceRepository(filePath); - const workspace = repository.open(root); + const workspace = repository.adopt(root); const renamed = repository.rename(workspace.workspaceId, 'Research'); expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ @@ -103,7 +108,7 @@ describe('DiskWorkspaceRepository', () => { // vitest.setup.ts points HUABU_DATA_DIR at a per-file temp directory. const dataDir = process.env.HUABU_DATA_DIR as string; const root = tempDir('huabu-workspace-store-root-'); - const workspace = getWorkspaceRepository().open(root); + const workspace = adoptWorkspaceDirectory(root); expect(JSON.parse(readFileSync(registryPath(dataDir), 'utf8'))).toEqual({ schemaVersion: 1, @@ -123,17 +128,17 @@ describe('DiskWorkspaceRepository', () => { const movedPath = path.join(parent, 'moved'); mkdirSync(originalPath); const filePath = registryPath(dataDir); - const original = new DiskWorkspaceRepository(filePath).open(originalPath); + const original = new DiskWorkspaceRepository(filePath).adopt(originalPath); renameSync(originalPath, movedPath); const reopened = new DiskWorkspaceRepository(filePath); - const moved = reopened.open(movedPath); + const moved = reopened.adopt(movedPath); - expect(moved).toEqual({ - ...original, - workspacePath: path.resolve(movedPath), - }); - expect(reopened.getByPath(originalPath)).toBeNull(); + expect(moved).toEqual(original); + expect(reopened.directoryOf(moved.workspaceId)).toBe( + path.resolve(movedPath), + ); + expect(reopened.at(originalPath)).toBeNull(); expect(reopened.get(original.workspaceId)).toEqual(moved); expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ schemaVersion: 1, @@ -151,7 +156,7 @@ describe('DiskWorkspaceRepository', () => { const secondRoot = tempDir('huabu-workspace-copy-'); const filePath = registryPath(tempDir('huabu-workspace-copy-data-')); const repository = new DiskWorkspaceRepository(filePath); - const first = repository.open(firstRoot); + const first = repository.adopt(firstRoot); mkdirSync(path.dirname(manifestPath(secondRoot)), { recursive: true }); writeFileSync( @@ -165,7 +170,7 @@ describe('DiskWorkspaceRepository', () => { ); expect(() => - new DiskWorkspaceRepository(filePath).open(secondRoot), + new DiskWorkspaceRepository(filePath).adopt(secondRoot), ).toThrow(/present at both.*copied Workspaces/i); }); @@ -174,7 +179,7 @@ describe('DiskWorkspaceRepository', () => { mkdirSync(path.dirname(manifestPath(root)), { recursive: true }); writeFileSync(manifestPath(root), '{ definitely not json', 'utf8'); - expect(() => new DiskWorkspaceRepository().open(root)).toThrow( + expect(() => new DiskWorkspaceRepository().adopt(root)).toThrow( /workspace manifest/i, ); expect(readFileSync(manifestPath(root), 'utf8')).toBe( @@ -185,25 +190,46 @@ describe('DiskWorkspaceRepository', () => { it('renames a Workspace durably and updates both indexes', () => { const root = tempDir('huabu-workspace-rename-'); const repository = new DiskWorkspaceRepository(); - const original = repository.open(root); + const original = repository.adopt(root); const renamed = repository.rename(original.workspaceId, 'Research'); expect(renamed).toEqual({ ...original, name: 'Research' }); expect(repository.get(original.workspaceId)).toEqual(renamed); - expect(repository.getByPath(root)).toEqual(renamed); - expect(new DiskWorkspaceRepository().open(root)).toEqual(renamed); + expect(repository.at(root)).toEqual(renamed); + expect(new DiskWorkspaceRepository().adopt(root)).toEqual(renamed); + }); + + it('refuses a name the manifest schema would reject, leaving it unchanged', () => { + const root = tempDir('huabu-workspace-blank-name-'); + const repository = new DiskWorkspaceRepository(); + const original = repository.adopt(root); + + // The schema is the only definition of a valid name, and it guards the + // write as well as the read — so an unusable one cannot be persisted and + // then blow up as a "malformed manifest" on some later read. + expect(() => repository.rename(original.workspaceId, ' ')).toThrow( + /workspace manifest.*invalid/i, + ); + expect(repository.get(original.workspaceId)).toEqual(original); + + // A name that only needs trimming is accepted, normalized once, by the + // same rule. + expect(repository.rename(original.workspaceId, ' Research ')).toEqual({ + ...original, + name: 'Research', + }); }); it('unregisters a Workspace without deleting its manifest', () => { const root = tempDir('huabu-workspace-remove-'); const filePath = registryPath(tempDir('huabu-workspace-remove-data-')); const repository = new DiskWorkspaceRepository(filePath); - const workspace = repository.open(root); + const workspace = repository.adopt(root); expect(repository.remove(workspace.workspaceId)).toBe(true); expect(repository.get(workspace.workspaceId)).toBeNull(); - expect(repository.getByPath(root)).toBeNull(); + expect(repository.at(root)).toBeNull(); expect(readFileSync(manifestPath(root), 'utf8')).toContain( workspace.workspaceId, ); @@ -216,8 +242,8 @@ describe('DiskWorkspaceRepository', () => { const gone = tempDir('huabu-workspace-gone-missing-'); const filePath = registryPath(dataDir); const repository = new DiskWorkspaceRepository(filePath); - const survivor = repository.open(kept); - const missing = repository.open(gone); + const survivor = repository.adopt(kept); + const missing = repository.adopt(gone); // An unplugged volume or a folder deleted in Finder looks exactly like // this, and it must not take down the whole listing. @@ -226,7 +252,7 @@ describe('DiskWorkspaceRepository', () => { const reopened = new DiskWorkspaceRepository(filePath); expect(reopened.list()).toEqual([survivor]); expect(reopened.get(missing.workspaceId)).toBeNull(); - expect(reopened.getByPath(gone)).toBeNull(); + expect(reopened.at(gone)).toBeNull(); // The registration survives, so the Workspace returns when its volume does. expect( ( @@ -244,7 +270,7 @@ describe('DiskWorkspaceRepository', () => { const filePath = registryPath(tempDir('huabu-workspace-damaged-data-')); const root = tempDir('huabu-workspace-damaged-'); const repository = new DiskWorkspaceRepository(filePath); - repository.open(root); + repository.adopt(root); writeFileSync(manifestPath(root), '{ definitely not json', 'utf8'); expect(() => new DiskWorkspaceRepository(filePath).list()).toThrow( @@ -259,16 +285,18 @@ describe('DiskWorkspaceRepository', () => { mkdirSync(root); const filePath = registryPath(dataDir); const repository = new DiskWorkspaceRepository(filePath); - const original = repository.open(root); + const original = repository.adopt(root); // Deleted outside Huabu and recreated by hand: the folder at this path is // a different Workspace now, and pointing Huabu at it must keep working. rmSync(root, { recursive: true, force: true }); mkdirSync(root); - const sameProcess = repository.open(root); + const sameProcess = repository.adopt(root); expect(sameProcess.workspaceId).not.toBe(original.workspaceId); - expect(sameProcess.workspacePath).toBe(path.resolve(root)); + expect(repository.directoryOf(sameProcess.workspaceId)).toBe( + path.resolve(root), + ); expect(repository.get(original.workspaceId)).toBeNull(); expect(repository.list()).toEqual([sameProcess]); @@ -276,7 +304,7 @@ describe('DiskWorkspaceRepository', () => { rmSync(root, { recursive: true, force: true }); mkdirSync(root); const afterRestart = new DiskWorkspaceRepository(filePath); - const readopted = afterRestart.open(root); + const readopted = afterRestart.adopt(root); expect(readopted.workspaceId).not.toBe(sameProcess.workspaceId); expect(afterRestart.list()).toEqual([readopted]); }); @@ -289,15 +317,17 @@ describe('DiskWorkspaceRepository', () => { mkdirSync(original); const filePath = registryPath(dataDir); const repository = new DiskWorkspaceRepository(filePath); - const first = repository.open(original); + const first = repository.adopt(original); renameSync(original, moved); mkdirSync(original); - const replacement = repository.open(original); - const relocated = repository.open(moved); + const replacement = repository.adopt(original); + const relocated = repository.adopt(moved); expect(relocated.workspaceId).toBe(first.workspaceId); - expect(relocated.workspacePath).toBe(path.resolve(moved)); + expect(repository.directoryOf(relocated.workspaceId)).toBe( + path.resolve(moved), + ); expect(repository.list()).toEqual([replacement, relocated]); }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index deaceb793..58c796d31 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -5,8 +5,9 @@ * Disk implementation of the Workspace storage port. * * A Workspace owns one stable id and display name in - * `/.huabu/workspace.json`. Existing Home folders predate that - * manifest, so opening one adopts it by creating the file once. + * `/workspace.json`, alongside the `space.json` each Space keeps. + * Existing Home folders predate that manifest, so adopting one creates the + * file once. * * The Server data directory holds a separate discovery index containing only * `workspaceId -> workspacePath`. That deliberate duplication is the minimum @@ -40,7 +41,6 @@ import type { WorkspaceRepository, } from '../../ports/workspace.js'; -export const WORKSPACE_MANIFEST_DIR = '.huabu'; export const WORKSPACE_MANIFEST_FILENAME = 'workspace.json'; export const WORKSPACE_REGISTRY_FILENAME = 'workspaces.json'; const WORKSPACE_MANIFEST_SCHEMA_VERSION = 1; @@ -84,11 +84,7 @@ export function workspaceRegistryPath(dataDir: string): string { } function manifestPath(workspacePath: string): string { - return path.join( - workspacePath, - WORKSPACE_MANIFEST_DIR, - WORKSPACE_MANIFEST_FILENAME, - ); + return path.join(workspacePath, WORKSPACE_MANIFEST_FILENAME); } function defaultWorkspaceName(workspacePath: string): string { @@ -135,6 +131,28 @@ function readManifest(filePath: string): WorkspaceManifest { return readManifestFile(filePath, false) as WorkspaceManifest; } +/** + * Write a manifest through the same schema that reads it. + * + * The schema is the only definition of what a valid manifest is — `name` is + * trimmed and must be non-empty — so validating on the way out means a caller + * cannot leave behind a file that fails validation on the way back in, and + * there is no second copy of the rule to drift. + */ +function writeManifest( + filePath: string, + manifest: WorkspaceManifest, +): WorkspaceManifest { + const result = workspaceManifestSchema.safeParse(manifest); + if (!result.success) { + throw new Error( + `Workspace manifest for ${filePath} is invalid: ${result.error.issues[0]?.message ?? 'invalid manifest'}`, + ); + } + atomicWriteJson(filePath, result.data); + return result.data; +} + function readWorkspaceRegistry(filePath: string): WorkspaceRegistryEntry[] { let parsed: unknown; try { @@ -194,13 +212,9 @@ function sameEntries( ); } -function toHandle( - manifest: WorkspaceManifest, - workspacePath: string, -): WorkspaceHandle { +function toHandle(manifest: WorkspaceManifest): WorkspaceHandle { return Object.freeze({ workspaceId: manifest.workspaceId, - workspacePath, name: manifest.name, }); } @@ -219,9 +233,8 @@ export function ensureWorkspaceManifestOnDisk( rawWorkspacePath: string, ): WorkspaceManifest { const workspacePath = path.resolve(rawWorkspacePath); - const metadataDir = path.join(workspacePath, WORKSPACE_MANIFEST_DIR); const filePath = manifestPath(workspacePath); - mkdirSync(metadataDir, { recursive: true }); + mkdirSync(workspacePath, { recursive: true }); const manifest: WorkspaceManifest = { schemaVersion: WORKSPACE_MANIFEST_SCHEMA_VERSION, @@ -277,7 +290,7 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { * Two registrations are stale rather than fatal: a Workspace whose folder * is gone or unmounted, and a path that some other Workspace has since * taken over. Both resolve to "not a member right now" so one unplugged - * drive cannot take down the whole collection; `open()` repairs the index + * drive cannot take down the whole collection; `adopt()` repairs the index * when the path is opened again. */ #hydrate(entry: WorkspaceRegistryEntry): WorkspaceHandle | null { @@ -300,10 +313,81 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { ); return null; } - return toHandle(manifest, entry.workspacePath); + return toHandle(manifest); + } + + #entryFor(workspaceId: string): WorkspaceRegistryEntry | undefined { + return this.#read().find((entry) => entry.workspaceId === workspaceId); + } + + // ─── Portable membership (WorkspaceRepository) ────────────────────────── + + get(workspaceId: string): WorkspaceHandle | null { + const entry = this.#entryFor(workspaceId); + return entry ? this.#hydrate(entry) : null; + } + + list(): readonly WorkspaceHandle[] { + const handles: WorkspaceHandle[] = []; + for (const entry of this.#read()) { + const handle = this.#hydrate(entry); + if (handle) handles.push(handle); + } + return handles; + } + + rename(workspaceId: string, name: string): WorkspaceHandle | null { + const entry = this.#entryFor(workspaceId); + if (!entry || !this.#hydrate(entry)) return null; + + const filePath = manifestPath(entry.workspacePath); + const manifest = readManifest(filePath); + if (manifest.workspaceId !== workspaceId) { + throw new Error( + `Workspace identity at ${entry.workspacePath} changed from ${workspaceId} to ${manifest.workspaceId}`, + ); + } + // The schema owns what a valid name is, on write as well as on read, so + // an unusable one is refused here rather than persisted and rejected on + // the next read. + return toHandle(writeManifest(filePath, { ...manifest, name })); + } + + remove(workspaceId: string): boolean { + const entries = this.#read(); + const next = entries.filter((entry) => entry.workspaceId !== workspaceId); + if (next.length === entries.length) return false; + this.#write(next); + return true; + } + + // ─── Disk locator surface ─────────────────────────────────────────────── + // + // Not part of the port: these answer *where* a Workspace is and how a real + // directory becomes one. Composition re-exposes them as the Workspace-level + // materialization capability, so application code never names this backend. + + /** The directory backing a registered Workspace, or null if it is not one. */ + directoryOf(workspaceId: string): string | null { + return this.#entryFor(workspaceId)?.workspacePath ?? null; } - open(rawWorkspacePath: string): WorkspaceHandle { + /** The registered Workspace materialized at a directory, if there is one. */ + at(rawWorkspacePath: string): WorkspaceHandle | null { + const workspacePath = path.resolve(rawWorkspacePath); + const entry = this.#read().find( + (candidate) => candidate.workspacePath === workspacePath, + ); + return entry ? this.#hydrate(entry) : null; + } + + /** + * Adopt a directory as a Workspace and record its membership. + * + * This is the one place the index is repaired against what is actually on + * disk, because it is the one place a caller names a directory. + */ + adopt(rawWorkspacePath: string): WorkspaceHandle { const workspacePath = path.resolve(rawWorkspacePath); const manifest = ensureWorkspaceManifestOnDisk(workspacePath); const entries = this.#read(); @@ -348,59 +432,6 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { if (!replaced) next.push(replacement); if (!sameEntries(entries, next)) this.#write(next); - return toHandle(manifest, workspacePath); - } - - get(workspaceId: string): WorkspaceHandle | null { - const entry = this.#read().find( - (candidate) => candidate.workspaceId === workspaceId, - ); - return entry ? this.#hydrate(entry) : null; - } - - getByPath(rawWorkspacePath: string): WorkspaceHandle | null { - const workspacePath = path.resolve(rawWorkspacePath); - const entry = this.#read().find( - (candidate) => candidate.workspacePath === workspacePath, - ); - return entry ? this.#hydrate(entry) : null; - } - - list(): readonly WorkspaceHandle[] { - const handles: WorkspaceHandle[] = []; - for (const entry of this.#read()) { - const handle = this.#hydrate(entry); - if (handle) handles.push(handle); - } - return handles; - } - - rename(workspaceId: string, rawName: string): WorkspaceHandle | null { - const current = this.get(workspaceId); - if (!current) return null; - - // Guards the manifest's own schema, not the route body: a repository - // caller that trims to nothing would otherwise write a file that fails - // validation on the next read. - const name = rawName.trim(); - if (!name) throw new Error('Workspace name is required'); - - const filePath = manifestPath(current.workspacePath); - const manifest = readManifest(filePath); - if (manifest.workspaceId !== workspaceId) { - throw new Error( - `Workspace identity at ${current.workspacePath} changed from ${workspaceId} to ${manifest.workspaceId}`, - ); - } - atomicWriteJson(filePath, { ...manifest, name }); - return toHandle({ ...manifest, name }, current.workspacePath); - } - - remove(workspaceId: string): boolean { - const entries = this.#read(); - const next = entries.filter((entry) => entry.workspaceId !== workspaceId); - if (next.length === entries.length) return false; - this.#write(next); - return true; + return toHandle(manifest); } } diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 8c6a6f760..75ad86456 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -68,6 +68,7 @@ export type { // ─── Storage ports and composition ───────────────────────────────────────── export { + adoptWorkspaceDirectory, canvasBlobs, createSpace, createStorage, @@ -80,6 +81,8 @@ export { setStorageForTesting, spaceDirectory, storageHealth, + workspaceAtDirectory, + workspaceDirectory, } from './storage.js'; export type { SpaceDeleteOutcome, Storage } from './storage.js'; export { diff --git a/apps/server/src/modules/storage/ports/workspace.ts b/apps/server/src/modules/storage/ports/workspace.ts index 93fa74192..ffdb3ea98 100644 --- a/apps/server/src/modules/storage/ports/workspace.ts +++ b/apps/server/src/modules/storage/ports/workspace.ts @@ -5,10 +5,17 @@ * Workspace storage port — membership and stable Workspace identity. * * A Workspace is the namespace that owns Spaces. The repository manages that - * collection; a handle identifies one member and, for the currently implemented - * Disk profile, carries its materialized path. A non-directory structured - * adapter must extend this locator contract when it is implemented rather than - * manufacture a fake filesystem path. + * collection by stable id; a handle carries the identity and the display name + * and nothing else. + * + * Where a Workspace *is* is deliberately absent. A directory path is a + * materialization fact, not an identity one, and a structured backend that + * keeps Workspaces in a database has no directory to name. Rather than force + * such an adapter to manufacture a path it cannot honor, locating a Workspace + * is a capability the composition root exposes separately, for the profiles + * that have one — the Workspace-level counterpart to `spaceDirectory()` + * (docs/proposals/multi-backend-storage.md §12.5.4). Adopting a directory as + * a Workspace lives there for the same reason. * * This file may not import a backend implementation or application workspace * lifecycle policy. @@ -16,16 +23,13 @@ export interface WorkspaceHandle { readonly workspaceId: string; - readonly workspacePath: string; readonly name: string; } export interface WorkspaceRepository { - open(workspacePath: string): WorkspaceHandle; get(workspaceId: string): WorkspaceHandle | null; - getByPath(workspacePath: string): WorkspaceHandle | null; list(): readonly WorkspaceHandle[]; rename(workspaceId: string, name: string): WorkspaceHandle | null; - /** Forget one handle without deleting any Workspace-owned data. */ + /** Forget one member without deleting any Workspace-owned data. */ remove(workspaceId: string): boolean; } diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index 1b900da35..a7bd65529 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -58,7 +58,10 @@ import type { SpaceDeleteFinishResult, StructuredStore, } from './ports/structured.js'; -import type { WorkspaceRepository } from './ports/workspace.js'; +import type { + WorkspaceHandle, + WorkspaceRepository, +} from './ports/workspace.js'; import type { Readable } from 'node:stream'; /** @@ -141,7 +144,7 @@ export function createStorage(profile: StorageProfile): Storage { // ─── Process-wide holder ──────────────────────────────────────────────────── let current: Storage | null = null; -let workspaces: WorkspaceRepository | null = null; +let workspaces: DiskWorkspaceRepository | null = null; let spaceCreateTail: Promise = Promise.resolve(); /** @@ -163,20 +166,55 @@ let spaceCreateTail: Promise = Promise.resolve(); * on-demand path. */ export function getWorkspaceRepository(): WorkspaceRepository { + return materializedWorkspaces(); +} + +/** + * The Workspace repository, narrowed to a backend that materializes + * Workspaces as real directories. + * + * This is the Workspace-level twin of {@link spaceDirectory}: the port + * deliberately says nothing about where a Workspace is, because a backend + * that keeps Workspaces in a database has no directory to name and must not + * be made to invent one. Only this module may ask a named backend where + * anything is, so the locator resolves here — and a non-materializing profile + * refuses outright rather than handing back a path that does not exist. + */ +function materializedWorkspaces(): DiskWorkspaceRepository { if (workspaces) return workspaces; const profile = parseStorageProfile(); - switch (profile.structured.kind) { - case 'disk': - workspaces = new DiskWorkspaceRepository( - workspaceRegistryPath(getDataDir()), - ); - return workspaces; - default: - throw new StorageProfileError( - `Workspace membership is not implemented for the "${profile.structured.kind}" structured backend.`, - ); + if (profile.structured.kind !== 'disk') { + throw new StorageProfileError( + `The "${profile.structured.kind}" structured backend does not materialize ` + + `Workspaces as directories. Implement a locator for it before using ` + + `directory-shaped Workspace activation.`, + ); } + workspaces = new DiskWorkspaceRepository(workspaceRegistryPath(getDataDir())); + return workspaces; +} + +/** + * Adopt a real directory as a Workspace, creating its manifest if the folder + * predates one, and record its membership. + */ +export function adoptWorkspaceDirectory( + workspacePath: string, +): WorkspaceHandle { + return materializedWorkspaces().adopt(workspacePath); +} + +/** The registered Workspace materialized at a directory, if there is one. */ +export function workspaceAtDirectory( + workspacePath: string, +): WorkspaceHandle | null { + return materializedWorkspaces().at(workspacePath); +} + +/** The directory backing a registered Workspace, or null if it is not one. */ +export function workspaceDirectory(workspaceId: string): string | null { + return materializedWorkspaces().directoryOf(workspaceId); } function defaultSpaceTitle( diff --git a/apps/server/src/modules/workspace-activation.test.ts b/apps/server/src/modules/workspace-activation.test.ts index fb3734438..bb1366a37 100644 --- a/apps/server/src/modules/workspace-activation.test.ts +++ b/apps/server/src/modules/workspace-activation.test.ts @@ -86,9 +86,7 @@ describe('workspace activation isolation', () => { await activateWorkspacePath(target, { workerPath, timeoutMs: 1_000 }); - expect(existsSync(path.join(target, '.huabu', 'workspace.json'))).toBe( - true, - ); + expect(existsSync(path.join(target, 'workspace.json'))).toBe(true); expect(getWorkspacePath()).toBe(path.resolve(target)); }); diff --git a/apps/server/src/modules/workspace-managed.test.ts b/apps/server/src/modules/workspace-managed.test.ts index 5a49901a9..b89d2cc29 100644 --- a/apps/server/src/modules/workspace-managed.test.ts +++ b/apps/server/src/modules/workspace-managed.test.ts @@ -28,8 +28,8 @@ it('adopts a legacy managed Workspace without exposing its host path', async () expect(workspace.isManagedMode()).toBe(true); expect(workspace.getWorkspaceHandle()).toMatchObject({ - workspacePath: path.resolve(root), name: path.basename(root), }); - expect(existsSync(path.join(root, '.huabu', 'workspace.json'))).toBe(true); + expect(workspace.getWorkspacePath()).toBe(path.resolve(root)); + expect(existsSync(path.join(root, 'workspace.json'))).toBe(true); }); diff --git a/apps/server/src/modules/workspace.route.test.ts b/apps/server/src/modules/workspace.route.test.ts index 6158642ae..7973ca1fa 100644 --- a/apps/server/src/modules/workspace.route.test.ts +++ b/apps/server/src/modules/workspace.route.test.ts @@ -20,7 +20,6 @@ vi.mock('./workspace.js', async (importOriginal) => { workspaceState.configured ? { workspaceId: workspaceState.workspaceId, - workspacePath: workspaceState.path, name: workspaceState.name, } : null, diff --git a/apps/server/src/modules/workspace.route.ts b/apps/server/src/modules/workspace.route.ts index aa270ddc6..e89c4fd50 100644 --- a/apps/server/src/modules/workspace.route.ts +++ b/apps/server/src/modules/workspace.route.ts @@ -15,7 +15,11 @@ import { WorkspaceActivationInProgressError, WorkspaceActivationTimeoutError, } from './workspace-activation.js'; -import { getWorkspaceHandle, isManagedMode } from './workspace.js'; +import { + getWorkspaceHandle, + getWorkspacePath, + isManagedMode, +} from './workspace.js'; import type { ApiErrorBody, @@ -157,8 +161,8 @@ async function buildWorkspaceState(): Promise { configured, workspaceId: workspace?.workspaceId ?? null, // Free-mode active absolute path. Never exposed in managed mode. - path: workspace && !managed ? workspace.workspacePath : null, - // Display label (basename). Safe to send in either mode. + path: workspace && !managed ? getWorkspacePath() : null, + // Persisted display label. Safe to send in either mode. name: workspace?.name ?? null, worldCanvasId: configured ? await getStructuredStore().spaces().worldId() diff --git a/apps/server/src/modules/workspace.test.ts b/apps/server/src/modules/workspace.test.ts index 0bcfe9273..0b990b885 100644 --- a/apps/server/src/modules/workspace.test.ts +++ b/apps/server/src/modules/workspace.test.ts @@ -5,7 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { getWorkspaceRepository } from './storage/index.js'; +import { workspaceAtDirectory } from './storage/index.js'; import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { acquireWorkspaceOperationLease, @@ -92,14 +92,8 @@ describe('workspace operation leases', () => { expect(() => commitWorkspacePath(refused)).toThrow( WorkspaceOperationInProgressError, ); - expect(existsSync(path.join(refused, '.huabu', 'workspace.json'))).toBe( - false, - ); - expect( - getWorkspaceRepository() - .list() - .some((workspace) => workspace.workspacePath === refused), - ).toBe(false); + expect(existsSync(path.join(refused, 'workspace.json'))).toBe(false); + expect(workspaceAtDirectory(refused)).toBeNull(); const registry = path.join( process.env.HUABU_DATA_DIR as string, 'storage', @@ -112,18 +106,20 @@ describe('workspace operation leases', () => { } }); - it('keeps the active path and manifest identity in one Workspace handle', () => { + it('separates the portable Workspace identity from its materialized path', () => { const current = tempDir('huabu-workspace-handle-'); setWorkspacePath(current); + // The handle is what a non-directory backend could also produce; where + // the Workspace lives is a Disk materialization fact resolved separately. const handle = getWorkspaceHandle(); - expect(handle).toMatchObject({ - workspacePath: path.resolve(current), + expect(handle).toEqual({ + workspaceId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), name: path.basename(current), }); - expect(handle?.workspaceId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, - ); + expect(getWorkspacePath()).toBe(path.resolve(current)); }); }); @@ -137,7 +133,7 @@ describe('workspace preparation', () => { // manifest belongs there — it is part of the blocking filesystem work // being contained — but membership stays a Server-process decision so the // durable registry keeps exactly one writer and no stale cache to lose. - expect(existsSync(path.join(root, '.huabu', 'workspace.json'))).toBe(true); - expect(getWorkspaceRepository().getByPath(root)).toBeNull(); + expect(existsSync(path.join(root, 'workspace.json'))).toBe(true); + expect(workspaceAtDirectory(root)).toBeNull(); }); }); diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index b3420707b..3e765f954 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -28,7 +28,7 @@ * Directory layout inside the active workspace (canvas-centric): * * / - * .huabu/workspace.json + * workspace.json * / * space.json * nodes/.md @@ -41,7 +41,7 @@ import path from 'node:path'; import { resetExternalNoteSessions } from './canvas/external-watcher.js'; import { refreshCanvasDirIndex } from './storage/canvas-dirs.js'; -import { getWorkspaceRepository } from './storage/index.js'; +import { adoptWorkspaceDirectory } from './storage/index.js'; import { prepareWorkspaceOnDisk } from './workspace-prepare.js'; import { invalidateUserSkill } from '../prompt/index.js'; @@ -49,7 +49,12 @@ import type { WorkspaceHandle } from './storage/index.js'; const ENV_KEY = 'HUABU_WORKSPACE'; +// Identity and location are separate facts: the handle is the portable +// Workspace identity, while the path is the Disk materialization the rest of +// the Server resolves every file against. A backend that does not materialize +// Workspaces would keep the former and have no latter. let _workspaceHandle: WorkspaceHandle | null = null; +let _workspacePath: string | null = null; let _managed = false; let _leasedWorkspacePath: string | null = null; let _workspaceOperationLeaseCount = 0; @@ -124,14 +129,14 @@ export function initWorkspaceFromEnv(): void { * Throws if no workspace has been activated yet. */ export function getWorkspacePath(): string { - if (!_workspaceHandle) { + if (!_workspacePath) { throw new Error( 'Workspace path has not been configured. ' + 'Activate a workspace first (PUT /api/workspace) or set ' + `${ENV_KEY} in the environment.`, ); } - return _workspaceHandle.workspacePath; + return _workspacePath; } /** The active immutable Workspace identity, or null before configuration. */ @@ -213,12 +218,8 @@ export function resolveWorkspacePath(newPath: string): string { export function commitWorkspacePath(rawPath: string): void { const resolvedPath = path.resolve(rawPath); assertWorkspacePathChangeAllowed(resolvedPath); - commitWorkspaceHandle(getWorkspaceRepository().open(resolvedPath)); -} - -/** Commit an already-prepared Workspace handle to process-local state. */ -function commitWorkspaceHandle(workspace: WorkspaceHandle): void { - _workspaceHandle = workspace; + _workspaceHandle = adoptWorkspaceDirectory(resolvedPath); + _workspacePath = resolvedPath; // Drop the cached canvas-dir index so subsequent lookups (used by // migrations and route handlers) reflect the new workspace. refreshCanvasDirIndex(); @@ -239,11 +240,6 @@ export function updateActiveWorkspaceHandle( workspace: WorkspaceHandle, ): boolean { if (_workspaceHandle?.workspaceId !== workspace.workspaceId) return false; - if (_workspaceHandle.workspacePath !== workspace.workspacePath) { - throw new Error( - 'Cannot change the active Workspace path during metadata update', - ); - } _workspaceHandle = workspace; return true; } diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts index e0d153411..03298489a 100644 --- a/apps/server/src/modules/workspaces.route.test.ts +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -8,16 +8,25 @@ const FIRST_ID = '00000000-0000-4000-8000-000000000001'; const SECOND_ID = '00000000-0000-4000-8000-000000000002'; const NEW_ID = '00000000-0000-4000-8000-000000000003'; +/** Portable identity, as the port defines it — no path. */ interface TestHandle { workspaceId: string; - workspacePath: string; name: string; } +/** One registered member: identity plus the directory backing it. */ +interface TestMember extends TestHandle { + workspacePath: string; +} + +function handleOf({ workspaceId, name }: TestMember): TestHandle { + return { workspaceId, name }; +} + const testState = vi.hoisted(() => ({ managed: false, active: null as TestHandle | null, - handles: [] as TestHandle[], + members: [] as TestMember[], })); const storageMocks = vi.hoisted(() => ({ @@ -34,50 +43,69 @@ const preprocessingMocks = vi.hoisted(() => ({ })); const repository = vi.hoisted(() => ({ - list: vi.fn(() => testState.handles), - get: vi.fn( - (workspaceId: string) => - testState.handles.find( - (workspace) => workspace.workspaceId === workspaceId, - ) ?? null, - ), - getByPath: vi.fn( - (workspacePath: string) => - testState.handles.find( - (workspace) => workspace.workspacePath === workspacePath, - ) ?? null, + list: vi.fn(() => + testState.members.map(({ workspaceId, name }) => ({ workspaceId, name })), ), - open: vi.fn((workspacePath: string) => { - const workspace = { - workspaceId: '00000000-0000-4000-8000-000000000003', - workspacePath, - name: workspacePath.split('/').filter(Boolean).at(-1) ?? 'Workspace', - }; - testState.handles.push(workspace); - return workspace; + get: vi.fn((workspaceId: string) => { + const member = testState.members.find( + (candidate) => candidate.workspaceId === workspaceId, + ); + return member + ? { workspaceId: member.workspaceId, name: member.name } + : null; }), rename: vi.fn((workspaceId: string, name: string) => { - const index = testState.handles.findIndex( - (workspace) => workspace.workspaceId === workspaceId, + const index = testState.members.findIndex( + (candidate) => candidate.workspaceId === workspaceId, ); if (index < 0) return null; - const workspace = { ...testState.handles[index], name } as TestHandle; - testState.handles[index] = workspace; - return workspace; + const member = { ...testState.members[index], name } as TestMember; + testState.members[index] = member; + return { workspaceId: member.workspaceId, name: member.name }; }), remove: vi.fn((workspaceId: string) => { - const index = testState.handles.findIndex( - (workspace) => workspace.workspaceId === workspaceId, + const index = testState.members.findIndex( + (candidate) => candidate.workspaceId === workspaceId, ); if (index < 0) return false; - testState.handles.splice(index, 1); + testState.members.splice(index, 1); return true; }), })); +/** The materialization tier the composition root exposes beside the port. */ +const locatorMocks = vi.hoisted(() => ({ + workspaceDirectory: vi.fn( + (workspaceId: string) => + testState.members.find( + (candidate) => candidate.workspaceId === workspaceId, + )?.workspacePath ?? null, + ), + workspaceAtDirectory: vi.fn((workspacePath: string) => { + const member = testState.members.find( + (candidate) => candidate.workspacePath === workspacePath, + ); + return member + ? { workspaceId: member.workspaceId, name: member.name } + : null; + }), + adoptWorkspaceDirectory: vi.fn((workspacePath: string) => { + const member: TestMember = { + workspaceId: '00000000-0000-4000-8000-000000000003', + workspacePath, + name: workspacePath.split('/').filter(Boolean).at(-1) ?? 'Workspace', + }; + testState.members.push(member); + return { workspaceId: member.workspaceId, name: member.name }; + }), +})); + vi.mock('./storage/index.js', () => ({ getWorkspaceRepository: () => repository, resetStorageCache: storageMocks.resetStorageCache, + adoptWorkspaceDirectory: locatorMocks.adoptWorkspaceDirectory, + workspaceAtDirectory: locatorMocks.workspaceAtDirectory, + workspaceDirectory: locatorMocks.workspaceDirectory, })); vi.mock('./workspace.js', () => ({ @@ -117,7 +145,7 @@ async function buildApp() { beforeEach(() => { testState.managed = false; - testState.handles = [ + testState.members = [ { workspaceId: FIRST_ID, workspacePath: '/tmp/first', @@ -129,11 +157,12 @@ beforeEach(() => { name: 'Second', }, ]; - testState.active = testState.handles[0] ?? null; + const first = testState.members[0]; + testState.active = first ? handleOf(first) : null; vi.clearAllMocks(); activationMocks.prepareWorkspacePath.mockImplementation(async (path) => path); activationMocks.activateWorkspacePath.mockImplementation(async (path) => { - testState.active = repository.getByPath(path); + testState.active = locatorMocks.workspaceAtDirectory(path); }); }); diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index 302ceb038..1c70d6c39 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -6,7 +6,13 @@ import { z } from 'zod'; import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; import { resetPreprocessDispatcher } from './preprocessing/index.js'; -import { getWorkspaceRepository, resetStorageCache } from './storage/index.js'; +import { + adoptWorkspaceDirectory, + getWorkspaceRepository, + resetStorageCache, + workspaceAtDirectory, + workspaceDirectory, +} from './storage/index.js'; import { activateWorkspacePath, prepareWorkspacePath, @@ -72,7 +78,9 @@ function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { return { workspaceId: workspace.workspaceId, name: workspace.name, - path: isManagedMode() ? null : workspace.workspacePath, + // A Workspace's directory is a materialization fact the handle does not + // carry, so it is resolved separately — and never sent in managed mode. + path: isManagedMode() ? null : workspaceDirectory(workspace.workspaceId), active, }; } @@ -157,7 +165,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { try { const workspacePath = resolveWorkspacePath(parsed.data.path); const repository = getWorkspaceRepository(); - const existing = repository.getByPath(workspacePath); + const existing = workspaceAtDirectory(workspacePath); if (existing) { if (!parsed.data.name) return reply.send(descriptor(existing)); const renamed = @@ -167,7 +175,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { } await prepareWorkspacePath(workspacePath); - let workspace = repository.open(workspacePath); + let workspace = adoptWorkspaceDirectory(workspacePath); if (parsed.data.name) { workspace = repository.rename(workspace.workspaceId, parsed.data.name) ?? @@ -199,9 +207,12 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { if (typeof parsedId !== 'string') return parsedId; const workspace = getWorkspaceRepository().get(parsedId); - if (!workspace) return sendError(reply, 404, 'Workspace not found'); + const workspacePath = workspaceDirectory(parsedId); + if (!workspace || !workspacePath) { + return sendError(reply, 404, 'Workspace not found'); + } try { - await activateWorkspacePath(workspace.workspacePath); + await activateWorkspacePath(workspacePath); resetStorageCache(); resetPreprocessDispatcher(); return reply.send(descriptor(getWorkspaceHandle() ?? workspace)); diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 8270afc42..34e9c0933 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -16,8 +16,7 @@ Runtime Home-folder activation prepares and migrates the selected directory in a workspaces.json # durable workspaceId -> absolute path index / - .huabu/ - workspace.json # stable Workspace identity + display name + workspace.json # stable Workspace identity + display name .world/ # hidden workspace-owned World Canvas space.json # stable generated canvasId; normal Canvas topology setting/ # user-owned, cross-canvas @@ -46,10 +45,12 @@ Runtime Home-folder activation prepares and migrates the selected directory in a Key points: -- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `.huabu/workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. - Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. Two live paths carrying the same id are rejected as a copied-identity conflict; a path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. - A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest. - Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. Managed mode adopts its Workspace while `app.ts` is still evaluating, before the boot sequence can await `initStorage()`; 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. +- `WorkspaceHandle` 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. The locator is the Workspace-level twin of `spaceDirectory()` and resolves in composition — `adoptWorkspaceDirectory()`, `workspaceAtDirectory()`, `workspaceDirectory()` — where a non-materializing profile refuses outright. `workspace.ts` therefore holds the active identity and the active path as two separate facts. +- The manifest schema is the single definition of a valid manifest and guards the write as well as the read, so a caller cannot persist a name that would fail validation on the next read. - Managed deployments expose exactly one Workspace — the active one. 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. - An ordinary Space **directory name** is derived from its title via `toSafeFilename(title)`, not from `canvasId`. The stable `canvasId` only lives inside `space.json`; the World is the reserved `.world` exception. - `SpaceRepository.list()` rescans on every call, returns ordinary Spaces only, skips ordinary directories without `space.json`, rejects malformed records (including a corrupt established World), and leaves ordering to the caller. `worldId()` resolves the hidden World from the same rescan and rejects missing or malformed state; it is the single World resolution point the collection's own create/delete/rename refusals also go through. From 796fe8c06d2d6a6e593563300cca434b145d9595 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 16:33:25 +0800 Subject: [PATCH 6/9] refactor(workspace): hide the Workspace manifest as .workspace.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01PBkVVRyUyfzbrExdyn3AjR --- .../storage/backends/disk/workspace-repository.ts | 9 +++++---- apps/server/src/modules/workspace-activation.test.ts | 2 +- apps/server/src/modules/workspace-managed.test.ts | 2 +- apps/server/src/modules/workspace.test.ts | 4 ++-- apps/server/src/modules/workspace.ts | 2 +- docs/architecture/canvas-storage.md | 4 ++-- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 58c796d31..33f1e8caf 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -5,9 +5,10 @@ * Disk implementation of the Workspace storage port. * * A Workspace owns one stable id and display name in - * `/workspace.json`, alongside the `space.json` each Space keeps. - * Existing Home folders predate that manifest, so adopting one creates the - * file once. + * `/.workspace.json`. It sits at the Home folder root — hidden, the + * way the Workspace's other Huabu-owned state such as `.world/` is, so the + * user's own Spaces and `setting/` stay the visible contents. Existing Home + * folders predate the manifest, so adopting one creates the file once. * * The Server data directory holds a separate discovery index containing only * `workspaceId -> workspacePath`. That deliberate duplication is the minimum @@ -41,7 +42,7 @@ import type { WorkspaceRepository, } from '../../ports/workspace.js'; -export const WORKSPACE_MANIFEST_FILENAME = 'workspace.json'; +export const WORKSPACE_MANIFEST_FILENAME = '.workspace.json'; export const WORKSPACE_REGISTRY_FILENAME = 'workspaces.json'; const WORKSPACE_MANIFEST_SCHEMA_VERSION = 1; const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1; diff --git a/apps/server/src/modules/workspace-activation.test.ts b/apps/server/src/modules/workspace-activation.test.ts index bb1366a37..d59d5b19c 100644 --- a/apps/server/src/modules/workspace-activation.test.ts +++ b/apps/server/src/modules/workspace-activation.test.ts @@ -86,7 +86,7 @@ describe('workspace activation isolation', () => { await activateWorkspacePath(target, { workerPath, timeoutMs: 1_000 }); - expect(existsSync(path.join(target, 'workspace.json'))).toBe(true); + expect(existsSync(path.join(target, '.workspace.json'))).toBe(true); expect(getWorkspacePath()).toBe(path.resolve(target)); }); diff --git a/apps/server/src/modules/workspace-managed.test.ts b/apps/server/src/modules/workspace-managed.test.ts index b89d2cc29..3e06686c0 100644 --- a/apps/server/src/modules/workspace-managed.test.ts +++ b/apps/server/src/modules/workspace-managed.test.ts @@ -31,5 +31,5 @@ it('adopts a legacy managed Workspace without exposing its host path', async () name: path.basename(root), }); expect(workspace.getWorkspacePath()).toBe(path.resolve(root)); - expect(existsSync(path.join(root, 'workspace.json'))).toBe(true); + expect(existsSync(path.join(root, '.workspace.json'))).toBe(true); }); diff --git a/apps/server/src/modules/workspace.test.ts b/apps/server/src/modules/workspace.test.ts index 0b990b885..56ec0378b 100644 --- a/apps/server/src/modules/workspace.test.ts +++ b/apps/server/src/modules/workspace.test.ts @@ -92,7 +92,7 @@ describe('workspace operation leases', () => { expect(() => commitWorkspacePath(refused)).toThrow( WorkspaceOperationInProgressError, ); - expect(existsSync(path.join(refused, 'workspace.json'))).toBe(false); + expect(existsSync(path.join(refused, '.workspace.json'))).toBe(false); expect(workspaceAtDirectory(refused)).toBeNull(); const registry = path.join( process.env.HUABU_DATA_DIR as string, @@ -133,7 +133,7 @@ describe('workspace preparation', () => { // manifest belongs there — it is part of the blocking filesystem work // being contained — but membership stays a Server-process decision so the // durable registry keeps exactly one writer and no stale cache to lose. - expect(existsSync(path.join(root, 'workspace.json'))).toBe(true); + expect(existsSync(path.join(root, '.workspace.json'))).toBe(true); expect(workspaceAtDirectory(root)).toBeNull(); }); }); diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index 3e765f954..2075675e7 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -28,7 +28,7 @@ * Directory layout inside the active workspace (canvas-centric): * * / - * workspace.json + * .workspace.json * / * space.json * nodes/.md diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 34e9c0933..98782d0d0 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -16,7 +16,7 @@ Runtime Home-folder activation prepares and migrates the selected directory in a workspaces.json # durable workspaceId -> absolute path index / - workspace.json # stable Workspace identity + display name + .workspace.json # stable Workspace identity + display name .world/ # hidden workspace-owned World Canvas space.json # stable generated canvasId; normal Canvas topology setting/ # user-owned, cross-canvas @@ -45,7 +45,7 @@ Runtime Home-folder activation prepares and migrates the selected directory in a Key points: -- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. - Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. Two live paths carrying the same id are rejected as a copied-identity conflict; a path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. - A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest. - Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. Managed mode adopts its Workspace while `app.ts` is still evaluating, before the boot sequence can await `initStorage()`; 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. From 94efba352533a8f312d32fc43d0a46fb99d44437 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 17:36:07 +0800 Subject: [PATCH 7/9] fix(storage): harden workspace activation and repository --- .../disk/workspace-repository.test.ts | 119 ++++++++++++------ .../backends/disk/workspace-repository.ts | 28 ++++- .../workspace-repository.contract.ts | 90 +++++++++++++ .../src/modules/storage/ports/workspace.ts | 13 +- apps/server/src/modules/storage/storage.ts | 20 +-- .../src/modules/workspace-activation.test.ts | 60 ++++++++- .../src/modules/workspace-activation.ts | 18 +-- apps/server/src/modules/workspace.ts | 73 +++++++++++ .../src/modules/workspaces.route.test.ts | 87 +++++++++++-- apps/server/src/modules/workspaces.route.ts | 77 +++++++++--- docs/architecture/canvas-storage.md | 8 +- docs/proposals/multi-backend-storage.md | 26 ++-- 12 files changed, 515 insertions(+), 104 deletions(-) create mode 100644 apps/server/src/modules/storage/ports/contracts/workspace-repository.contract.ts diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index d85e2497f..5a5ee936b 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -7,6 +7,7 @@ import { readFileSync, renameSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -17,6 +18,7 @@ import { WORKSPACE_MANIFEST_FILENAME, WORKSPACE_REGISTRY_FILENAME, } from './workspace-repository.js'; +import { describeWorkspaceRepositoryContract } from '../../ports/contracts/workspace-repository.contract.js'; import { adoptWorkspaceDirectory } from '../../storage.js'; describe('DiskWorkspaceRepository', () => { @@ -42,6 +44,21 @@ describe('DiskWorkspaceRepository', () => { } }); + describeWorkspaceRepositoryContract('Disk', async () => { + const repository = new DiskWorkspaceRepository(); + return { + repository, + create: async (name: string) => { + const workspace = repository.adopt( + tempDir('huabu-workspace-contract-'), + ); + const renamed = await repository.rename(workspace.workspaceId, name); + if (!renamed) throw new Error('Expected adopted Workspace to rename'); + return renamed; + }, + }; + }); + it('adopts a legacy Workspace by creating a stable manifest', () => { const root = tempDir('huabu-legacy-workspace-'); const firstRepository = new DiskWorkspaceRepository(); @@ -70,27 +87,27 @@ describe('DiskWorkspaceRepository', () => { expect(reopened).toEqual(first); }); - it('indexes adopted Workspaces by both stable id and canonical path', () => { + it('indexes adopted Workspaces by both stable id and canonical path', async () => { const repository = new DiskWorkspaceRepository(); const firstRoot = tempDir('huabu-workspace-first-'); const first = repository.adopt(firstRoot); const second = repository.adopt(tempDir('huabu-workspace-second-')); - expect(repository.get(first.workspaceId)).toEqual(first); + await expect(repository.get(first.workspaceId)).resolves.toEqual(first); expect(repository.at(firstRoot)).toEqual(first); expect(repository.directoryOf(first.workspaceId)).toBe( path.resolve(firstRoot), ); - expect(repository.list()).toEqual([first, second]); + await expect(repository.list()).resolves.toEqual([first, second]); }); - it('persists only the stable id-to-path index and rehydrates metadata after restart', () => { + it('persists only the stable id-to-path index and rehydrates metadata after restart', async () => { const dataDir = tempDir('huabu-workspace-data-'); const root = tempDir('huabu-workspace-persisted-'); const filePath = registryPath(dataDir); const repository = new DiskWorkspaceRepository(filePath); const workspace = repository.adopt(root); - const renamed = repository.rename(workspace.workspaceId, 'Research'); + const renamed = await repository.rename(workspace.workspaceId, 'Research'); expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ schemaVersion: 1, @@ -101,7 +118,9 @@ describe('DiskWorkspaceRepository', () => { }, ], }); - expect(new DiskWorkspaceRepository(filePath).list()).toEqual([renamed]); + await expect(new DiskWorkspaceRepository(filePath).list()).resolves.toEqual( + [renamed], + ); }); it('stores the production registry under the Disk backend data directory', () => { @@ -121,7 +140,7 @@ describe('DiskWorkspaceRepository', () => { }); }); - it('recognizes an externally moved Workspace by id and replaces its registered path', () => { + it('recognizes an externally moved Workspace by id and replaces its registered path', async () => { const dataDir = tempDir('huabu-workspace-move-data-'); const parent = tempDir('huabu-workspace-move-root-'); const originalPath = path.join(parent, 'original'); @@ -139,7 +158,7 @@ describe('DiskWorkspaceRepository', () => { path.resolve(movedPath), ); expect(reopened.at(originalPath)).toBeNull(); - expect(reopened.get(original.workspaceId)).toEqual(moved); + await expect(reopened.get(original.workspaceId)).resolves.toEqual(moved); expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ schemaVersion: 1, workspaces: [ @@ -151,6 +170,25 @@ describe('DiskWorkspaceRepository', () => { }); }); + it('treats a symlink alias as the same Workspace directory, not a copy', async () => { + const root = tempDir('huabu-workspace-symlink-target-'); + const aliasParent = tempDir('huabu-workspace-symlink-parent-'); + const alias = path.join(aliasParent, 'alias'); + symlinkSync(root, alias, process.platform === 'win32' ? 'junction' : 'dir'); + const repository = new DiskWorkspaceRepository( + registryPath(tempDir('huabu-workspace-symlink-data-')), + ); + const throughAlias = repository.adopt(alias); + + expect(() => repository.adopt(root)).not.toThrow(); + await expect(repository.get(throughAlias.workspaceId)).resolves.toEqual( + throughAlias, + ); + expect(repository.directoryOf(throughAlias.workspaceId)).toBe( + path.resolve(root), + ); + }); + it('rejects two different paths that claim the same Workspace identity', () => { const firstRoot = tempDir('huabu-workspace-original-'); const secondRoot = tempDir('huabu-workspace-copy-'); @@ -187,20 +225,22 @@ describe('DiskWorkspaceRepository', () => { ); }); - it('renames a Workspace durably and updates both indexes', () => { + it('renames a Workspace durably and updates both indexes', async () => { const root = tempDir('huabu-workspace-rename-'); const repository = new DiskWorkspaceRepository(); const original = repository.adopt(root); - const renamed = repository.rename(original.workspaceId, 'Research'); + const renamed = await repository.rename(original.workspaceId, 'Research'); expect(renamed).toEqual({ ...original, name: 'Research' }); - expect(repository.get(original.workspaceId)).toEqual(renamed); + await expect(repository.get(original.workspaceId)).resolves.toEqual( + renamed, + ); expect(repository.at(root)).toEqual(renamed); expect(new DiskWorkspaceRepository().adopt(root)).toEqual(renamed); }); - it('refuses a name the manifest schema would reject, leaving it unchanged', () => { + it('refuses a name the manifest schema would reject, leaving it unchanged', async () => { const root = tempDir('huabu-workspace-blank-name-'); const repository = new DiskWorkspaceRepository(); const original = repository.adopt(root); @@ -208,35 +248,38 @@ describe('DiskWorkspaceRepository', () => { // The schema is the only definition of a valid name, and it guards the // write as well as the read — so an unusable one cannot be persisted and // then blow up as a "malformed manifest" on some later read. - expect(() => repository.rename(original.workspaceId, ' ')).toThrow( - /workspace manifest.*invalid/i, + await expect( + repository.rename(original.workspaceId, ' '), + ).rejects.toThrow(/workspace manifest.*invalid/i); + await expect(repository.get(original.workspaceId)).resolves.toEqual( + original, ); - expect(repository.get(original.workspaceId)).toEqual(original); // A name that only needs trimming is accepted, normalized once, by the // same rule. - expect(repository.rename(original.workspaceId, ' Research ')).toEqual({ - ...original, - name: 'Research', - }); + await expect( + repository.rename(original.workspaceId, ' Research '), + ).resolves.toEqual({ ...original, name: 'Research' }); }); - it('unregisters a Workspace without deleting its manifest', () => { + it('unregisters a Workspace without deleting its manifest', async () => { const root = tempDir('huabu-workspace-remove-'); const filePath = registryPath(tempDir('huabu-workspace-remove-data-')); const repository = new DiskWorkspaceRepository(filePath); const workspace = repository.adopt(root); - expect(repository.remove(workspace.workspaceId)).toBe(true); - expect(repository.get(workspace.workspaceId)).toBeNull(); + await expect(repository.remove(workspace.workspaceId)).resolves.toBe(true); + await expect(repository.get(workspace.workspaceId)).resolves.toBeNull(); expect(repository.at(root)).toBeNull(); expect(readFileSync(manifestPath(root), 'utf8')).toContain( workspace.workspaceId, ); - expect(new DiskWorkspaceRepository(filePath).list()).toEqual([]); + await expect(new DiskWorkspaceRepository(filePath).list()).resolves.toEqual( + [], + ); }); - it('keeps the collection readable when one registered folder is gone', () => { + it('keeps the collection readable when one registered folder is gone', async () => { const dataDir = tempDir('huabu-workspace-gone-data-'); const kept = tempDir('huabu-workspace-gone-kept-'); const gone = tempDir('huabu-workspace-gone-missing-'); @@ -250,8 +293,8 @@ describe('DiskWorkspaceRepository', () => { rmSync(gone, { recursive: true, force: true }); const reopened = new DiskWorkspaceRepository(filePath); - expect(reopened.list()).toEqual([survivor]); - expect(reopened.get(missing.workspaceId)).toBeNull(); + await expect(reopened.list()).resolves.toEqual([survivor]); + await expect(reopened.get(missing.workspaceId)).resolves.toBeNull(); expect(reopened.at(gone)).toBeNull(); // The registration survives, so the Workspace returns when its volume does. expect( @@ -262,23 +305,23 @@ describe('DiskWorkspaceRepository', () => { ).workspaces, ).toHaveLength(2); // ... and it can still be unregistered while unreachable. - expect(reopened.remove(missing.workspaceId)).toBe(true); - expect(reopened.list()).toEqual([survivor]); + await expect(reopened.remove(missing.workspaceId)).resolves.toBe(true); + await expect(reopened.list()).resolves.toEqual([survivor]); }); - it('still reports a malformed manifest rather than hiding it as unreachable', () => { + it('still reports a malformed manifest rather than hiding it as unreachable', async () => { const filePath = registryPath(tempDir('huabu-workspace-damaged-data-')); const root = tempDir('huabu-workspace-damaged-'); const repository = new DiskWorkspaceRepository(filePath); repository.adopt(root); writeFileSync(manifestPath(root), '{ definitely not json', 'utf8'); - expect(() => new DiskWorkspaceRepository(filePath).list()).toThrow( + await expect(new DiskWorkspaceRepository(filePath).list()).rejects.toThrow( /workspace manifest/i, ); }); - it('re-adopts a registered path whose folder was replaced', () => { + it('re-adopts a registered path whose folder was replaced', async () => { const dataDir = tempDir('huabu-workspace-replaced-data-'); const parent = tempDir('huabu-workspace-replaced-root-'); const root = path.join(parent, 'home'); @@ -297,8 +340,8 @@ describe('DiskWorkspaceRepository', () => { expect(repository.directoryOf(sameProcess.workspaceId)).toBe( path.resolve(root), ); - expect(repository.get(original.workspaceId)).toBeNull(); - expect(repository.list()).toEqual([sameProcess]); + await expect(repository.get(original.workspaceId)).resolves.toBeNull(); + await expect(repository.list()).resolves.toEqual([sameProcess]); // And the same holds for a Server that only sees it after a restart. rmSync(root, { recursive: true, force: true }); @@ -306,10 +349,10 @@ describe('DiskWorkspaceRepository', () => { const afterRestart = new DiskWorkspaceRepository(filePath); const readopted = afterRestart.adopt(root); expect(readopted.workspaceId).not.toBe(sameProcess.workspaceId); - expect(afterRestart.list()).toEqual([readopted]); + await expect(afterRestart.list()).resolves.toEqual([readopted]); }); - it('frees a moved Workspace to keep its identity when its old path is reused', () => { + it('frees a moved Workspace to keep its identity when its old path is reused', async () => { const dataDir = tempDir('huabu-workspace-swap-data-'); const parent = tempDir('huabu-workspace-swap-root-'); const original = path.join(parent, 'original'); @@ -328,10 +371,10 @@ describe('DiskWorkspaceRepository', () => { expect(repository.directoryOf(relocated.workspaceId)).toBe( path.resolve(moved), ); - expect(repository.list()).toEqual([replacement, relocated]); + await expect(repository.list()).resolves.toEqual([replacement, relocated]); }); - it('rejects a malformed durable registry instead of discarding it', () => { + it('rejects a malformed durable registry instead of discarding it', async () => { const filePath = registryPath(tempDir('huabu-workspace-corrupt-data-')); mkdirSync(path.dirname(filePath), { recursive: true }); writeFileSync( @@ -343,7 +386,7 @@ describe('DiskWorkspaceRepository', () => { 'utf8', ); - expect(() => new DiskWorkspaceRepository(filePath).list()).toThrow( + await expect(new DiskWorkspaceRepository(filePath).list()).rejects.toThrow( /workspace registry.*invalid/i, ); }); diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 33f1e8caf..41364b574 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -27,6 +27,7 @@ import { randomUUID } from 'node:crypto'; import { mkdirSync, readFileSync, + realpathSync, writeFileSync, type WriteFileOptions, } from 'node:fs'; @@ -213,6 +214,17 @@ function sameEntries( ); } +/** Compare two existing directory spellings without conflating real copies. */ +function samePhysicalDirectory(left: string, right: string): boolean { + if (path.resolve(left) === path.resolve(right)) return true; + try { + return realpathSync.native(left) === realpathSync.native(right); + } catch (error) { + if (isUnreachable(error)) return false; + throw error; + } +} + function toHandle(manifest: WorkspaceManifest): WorkspaceHandle { return Object.freeze({ workspaceId: manifest.workspaceId, @@ -323,12 +335,12 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { // ─── Portable membership (WorkspaceRepository) ────────────────────────── - get(workspaceId: string): WorkspaceHandle | null { + async get(workspaceId: string): Promise { const entry = this.#entryFor(workspaceId); return entry ? this.#hydrate(entry) : null; } - list(): readonly WorkspaceHandle[] { + async list(): Promise { const handles: WorkspaceHandle[] = []; for (const entry of this.#read()) { const handle = this.#hydrate(entry); @@ -337,7 +349,10 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { return handles; } - rename(workspaceId: string, name: string): WorkspaceHandle | null { + async rename( + workspaceId: string, + name: string, + ): Promise { const entry = this.#entryFor(workspaceId); if (!entry || !this.#hydrate(entry)) return null; @@ -354,7 +369,7 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { return toHandle(writeManifest(filePath, { ...manifest, name })); } - remove(workspaceId: string): boolean { + async remove(workspaceId: string): Promise { const entries = this.#read(); const next = entries.filter((entry) => entry.workspaceId !== workspaceId); if (next.length === entries.length) return false; @@ -406,7 +421,10 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { manifestPath(elsewhere.workspacePath), true, ); - if (previous?.workspaceId === manifest.workspaceId) { + if ( + previous?.workspaceId === manifest.workspaceId && + !samePhysicalDirectory(elsewhere.workspacePath, workspacePath) + ) { throw new Error( `Workspace identity ${manifest.workspaceId} is present at both ${elsewhere.workspacePath} and ${workspacePath}; copied Workspaces must receive distinct identities`, ); diff --git a/apps/server/src/modules/storage/ports/contracts/workspace-repository.contract.ts b/apps/server/src/modules/storage/ports/contracts/workspace-repository.contract.ts new file mode 100644 index 000000000..6a55c9a38 --- /dev/null +++ b/apps/server/src/modules/storage/ports/contracts/workspace-repository.contract.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Reusable minimum contract for backend-neutral Workspace membership. */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import type { WorkspaceHandle, WorkspaceRepository } from '../workspace.js'; + +export interface WorkspaceRepositoryContractHarness { + readonly repository: WorkspaceRepository; + readonly create: (name: string) => Promise; + readonly cleanup?: () => Promise | void; +} + +export function describeWorkspaceRepositoryContract( + name: string, + createHarness: () => + | Promise + | WorkspaceRepositoryContractHarness, +): void { + describe(`WorkspaceRepository contract: ${name}`, () => { + let harness: WorkspaceRepositoryContractHarness | null = null; + + async function open(): Promise { + harness = await createHarness(); + return harness; + } + + afterEach(async () => { + await harness?.cleanup?.(); + harness = null; + }); + + it('starts with no registered Workspaces', async () => { + const { repository } = await open(); + + await expect(repository.list()).resolves.toEqual([]); + }); + + it('gets and lists registered Workspace identities', async () => { + const { repository, create } = await open(); + const first = await create('First'); + const second = await create('Second'); + + await expect(repository.get(first.workspaceId)).resolves.toEqual(first); + await expect(repository.get(second.workspaceId)).resolves.toEqual(second); + const workspaces = await repository.list(); + expect(workspaces).toHaveLength(2); + expect(workspaces).toEqual(expect.arrayContaining([first, second])); + }); + + it('renames a registered Workspace and returns its authoritative handle', async () => { + const { repository, create } = await open(); + const workspace = await create('Before'); + + await expect( + repository.rename(workspace.workspaceId, 'After'), + ).resolves.toEqual({ ...workspace, name: 'After' }); + await expect(repository.get(workspace.workspaceId)).resolves.toEqual({ + ...workspace, + name: 'After', + }); + }); + + it('unregisters membership without treating a missing id as success', async () => { + const { repository, create } = await open(); + const workspace = await create('Disposable'); + + await expect(repository.remove(workspace.workspaceId)).resolves.toBe( + true, + ); + await expect(repository.get(workspace.workspaceId)).resolves.toBeNull(); + await expect(repository.remove(workspace.workspaceId)).resolves.toBe( + false, + ); + }); + + it('returns null for unknown identities', async () => { + const { repository } = await open(); + + await expect( + repository.get('00000000-0000-4000-8000-000000000099'), + ).resolves.toBeNull(); + await expect( + repository.rename('00000000-0000-4000-8000-000000000099', 'Missing'), + ).resolves.toBeNull(); + }); + }); +} diff --git a/apps/server/src/modules/storage/ports/workspace.ts b/apps/server/src/modules/storage/ports/workspace.ts index ffdb3ea98..242e4de2a 100644 --- a/apps/server/src/modules/storage/ports/workspace.ts +++ b/apps/server/src/modules/storage/ports/workspace.ts @@ -19,6 +19,11 @@ * * This file may not import a backend implementation or application workspace * lifecycle policy. + * + * Every operation is async so a connection-backed adapter can serve all + * Workspace namespaces through one already-open connection or pool. Selecting + * the active Workspace is lifecycle policy above this port, not a reason to + * reconnect the repository. */ export interface WorkspaceHandle { @@ -27,9 +32,9 @@ export interface WorkspaceHandle { } export interface WorkspaceRepository { - get(workspaceId: string): WorkspaceHandle | null; - list(): readonly WorkspaceHandle[]; - rename(workspaceId: string, name: string): WorkspaceHandle | null; + get(workspaceId: string): Promise; + list(): Promise; + rename(workspaceId: string, name: string): Promise; /** Forget one member without deleting any Workspace-owned data. */ - remove(workspaceId: string): boolean; + remove(workspaceId: string): Promise; } diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index a7bd65529..b58d8fd38 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -8,10 +8,12 @@ * validated {@link StorageProfile} and holds them for the process. This is * the only place that maps a backend kind to an adapter. * - * The module-level holder mirrors `workspace.ts`, which keeps the active - * workspace path in module state set once at boot. Call {@link initStorage} - * from the server entry point so a bad profile fails at startup with an - * actionable message. + * The module-level holder is process-wide, while `workspace.ts` selects the + * active namespace used through it. Workspace activation never reconstructs + * these backend connections: a SQL adapter serves every Workspace through one + * live connection or pool and scopes repository/handle operations by id. Call + * {@link initStorage} from the server entry point so a bad profile fails at + * startup with an actionable message. * * Anything that reaches for storage without that — tests, scripts — builds * the adapters on demand. That path is synchronous, so it cannot `await @@ -159,11 +161,11 @@ let spaceCreateTail: Promise = Promise.resolve(); * connections to hold. * * So the composition root owns this axis separately. It still maps a backend - * kind to exactly one adapter, and it holds one instance for the process. A - * future structured backend whose Workspace membership lives in a connection - * has to answer the boot-order question here — by making Workspace adoption - * part of the awaited startup sequence — rather than by widening the - * on-demand path. + * kind to exactly one adapter, and it holds one instance for the process. + * Connection-backed adapters expose Workspace membership through that same + * process-wide connection or pool; switching the active Workspace selects a + * namespace and never drops or reconnects the backend. Their repository is + * wired during awaited startup rather than through the on-demand path. */ export function getWorkspaceRepository(): WorkspaceRepository { return materializedWorkspaces(); diff --git a/apps/server/src/modules/workspace-activation.test.ts b/apps/server/src/modules/workspace-activation.test.ts index d59d5b19c..6ea277697 100644 --- a/apps/server/src/modules/workspace-activation.test.ts +++ b/apps/server/src/modules/workspace-activation.test.ts @@ -12,7 +12,12 @@ import { WorkspaceActivationInProgressError, WorkspaceActivationTimeoutError, } from './workspace-activation.js'; -import { getWorkspacePath, setWorkspacePath } from './workspace.js'; +import { + acquireWorkspaceOperationLease, + getWorkspacePath, + setWorkspacePath, + WorkspaceOperationInProgressError, +} from './workspace.js'; describe('workspace activation isolation', () => { const roots: string[] = []; @@ -68,6 +73,30 @@ describe('workspace activation isolation', () => { expect(getWorkspacePath()).toBe(path.resolve(previous)); }); + it('refuses a leased switch before its preparation worker can touch the target', async () => { + const previous = tempDir('huabu-workspace-leased-'); + const parent = tempDir('huabu-workspace-refused-parent-'); + const target = path.join(parent, 'refused'); + const workerPath = worker(` + const { mkdirSync, writeFileSync } = await import('node:fs'); + mkdirSync(process.argv[2], { recursive: true }); + writeFileSync(new URL('.worker-ran', 'file://' + process.argv[2] + '/'), 'ran'); + process.send({ ok: true }); + `); + setWorkspacePath(previous); + const lease = acquireWorkspaceOperationLease(); + + try { + await expect( + activateWorkspacePath(target, { workerPath, timeoutMs: 1_000 }), + ).rejects.toBeInstanceOf(WorkspaceOperationInProgressError); + expect(existsSync(target)).toBe(false); + expect(getWorkspacePath()).toBe(path.resolve(previous)); + } finally { + lease.release(); + } + }); + it('prepares a Workspace without changing the active Workspace', async () => { const previous = tempDir('huabu-workspace-previous-'); const next = tempDir('huabu-workspace-prepared-'); @@ -105,4 +134,33 @@ describe('workspace activation isolation', () => { ).rejects.toBeInstanceOf(WorkspaceActivationInProgressError); await expect(first).rejects.toBeInstanceOf(WorkspaceActivationTimeoutError); }); + + it('does not admit a new operation into the old Workspace during activation', async () => { + const previous = tempDir('huabu-workspace-operation-previous-'); + const directSwitch = path.join( + tempDir('huabu-workspace-direct-parent-'), + 'direct-switch', + ); + const workerPath = worker(`setInterval(() => {}, 1_000);`); + setWorkspacePath(previous); + const activation = activateWorkspacePath( + tempDir('huabu-workspace-operation-next-'), + { workerPath, timeoutMs: 50 }, + ); + + expect(() => acquireWorkspaceOperationLease()).toThrow( + WorkspaceActivationInProgressError, + ); + expect(() => setWorkspacePath(directSwitch)).toThrow( + WorkspaceActivationInProgressError, + ); + expect(existsSync(directSwitch)).toBe(false); + await expect(activation).rejects.toBeInstanceOf( + WorkspaceActivationTimeoutError, + ); + + const lease = acquireWorkspaceOperationLease(); + expect(lease.workspacePath).toBe(path.resolve(previous)); + lease.release(); + }); }); diff --git a/apps/server/src/modules/workspace-activation.ts b/apps/server/src/modules/workspace-activation.ts index afc2d2e2f..f78634902 100644 --- a/apps/server/src/modules/workspace-activation.ts +++ b/apps/server/src/modules/workspace-activation.ts @@ -16,9 +16,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - commitWorkspacePath, + beginWorkspaceActivation, isManagedMode, resolveWorkspacePath, + WorkspaceActivationInProgressError, } from './workspace.js'; import { getLogger } from '../utils/logger.js'; @@ -55,12 +56,7 @@ export class WorkspaceActivationTimeoutError extends Error { } } -export class WorkspaceActivationInProgressError extends Error { - constructor() { - super('Another workspace activation is already in progress'); - this.name = 'WorkspaceActivationInProgressError'; - } -} +export { WorkspaceActivationInProgressError }; interface PreparationOptions { timeoutMs?: number; @@ -205,5 +201,11 @@ export async function activateWorkspacePath( newPath: string, options: PreparationOptions = {}, ): Promise { - commitWorkspacePath(await prepareWorkspacePath(newPath, options)); + const reservation = beginWorkspaceActivation(newPath); + try { + await prepareWorkspacePath(reservation.workspacePath, options); + reservation.commit(); + } finally { + reservation.release(); + } } diff --git a/apps/server/src/modules/workspace.ts b/apps/server/src/modules/workspace.ts index 2075675e7..6d898fda5 100644 --- a/apps/server/src/modules/workspace.ts +++ b/apps/server/src/modules/workspace.ts @@ -58,6 +58,7 @@ let _workspacePath: string | null = null; let _managed = false; let _leasedWorkspacePath: string | null = null; let _workspaceOperationLeaseCount = 0; +let _activatingWorkspacePath: string | null = null; /** * A short-lived claim that keeps an async operation on one workspace. @@ -71,6 +72,13 @@ export interface WorkspaceOperationLease { release(): void; } +/** A process-local reservation for one pending active-Workspace switch. */ +export interface WorkspaceActivationReservation { + readonly workspacePath: string; + commit(): void; + release(): void; +} + /** Raised when a workspace switch would strand an in-flight operation. */ export class WorkspaceOperationInProgressError extends Error { constructor() { @@ -81,6 +89,14 @@ export class WorkspaceOperationInProgressError extends Error { } } +/** Raised when another switch already owns the active-Workspace reservation. */ +export class WorkspaceActivationInProgressError extends Error { + constructor() { + super('Another workspace activation is already in progress'); + this.name = 'WorkspaceActivationInProgressError'; + } +} + // ────────────────────────────────────────────────────────────────────── // Mode + lifecycle // ────────────────────────────────────────────────────────────────────── @@ -154,6 +170,13 @@ export function getWorkspaceHandle(): WorkspaceHandle | null { export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { const workspacePath = getWorkspacePath(); + if ( + _activatingWorkspacePath !== null && + _activatingWorkspacePath !== workspacePath + ) { + throw new WorkspaceActivationInProgressError(); + } + if ( _workspaceOperationLeaseCount > 0 && _leasedWorkspacePath !== workspacePath @@ -178,6 +201,44 @@ export function acquireWorkspaceOperationLease(): WorkspaceOperationLease { }); } +/** + * Reserve a namespace switch before asynchronous preparation can touch it. + * + * The reservation closes both sides of the race: an existing operation makes + * activation fail before the target is prepared, while new operations cannot + * start and strand themselves in the old Workspace during preparation. + */ +export function beginWorkspaceActivation( + newPath: string, +): WorkspaceActivationReservation { + const workspacePath = resolveWorkspacePath(newPath); + if (_activatingWorkspacePath !== null) { + throw new WorkspaceActivationInProgressError(); + } + assertWorkspacePathChangeAllowed(workspacePath); + _activatingWorkspacePath = workspacePath; + + let released = false; + let committed = false; + return Object.freeze({ + workspacePath, + commit(): void { + if (released || committed || _activatingWorkspacePath !== workspacePath) { + throw new WorkspaceActivationInProgressError(); + } + commitResolvedWorkspacePath(workspacePath); + committed = true; + }, + release(): void { + if (released) return; + released = true; + if (_activatingWorkspacePath === workspacePath) { + _activatingWorkspacePath = null; + } + }, + }); +} + /** * (Free mode) Activate any absolute path as the current workspace and * create the workspace folder. Rejected in managed mode — the workspace @@ -193,6 +254,7 @@ export function setWorkspacePath(newPath: string): void { ); } const resolvedPath = resolveWorkspacePath(newPath); + assertNoWorkspaceActivationInProgress(); assertWorkspacePathChangeAllowed(resolvedPath); prepareWorkspaceOnDisk(resolvedPath); commitWorkspacePath(resolvedPath); @@ -217,6 +279,11 @@ export function resolveWorkspacePath(newPath: string): string { */ export function commitWorkspacePath(rawPath: string): void { const resolvedPath = path.resolve(rawPath); + assertNoWorkspaceActivationInProgress(); + commitResolvedWorkspacePath(resolvedPath); +} + +function commitResolvedWorkspacePath(resolvedPath: string): void { assertWorkspacePathChangeAllowed(resolvedPath); _workspaceHandle = adoptWorkspaceDirectory(resolvedPath); _workspacePath = resolvedPath; @@ -273,3 +340,9 @@ function assertWorkspacePathChangeAllowed(resolvedPath: string): void { throw new WorkspaceOperationInProgressError(); } } + +function assertNoWorkspaceActivationInProgress(): void { + if (_activatingWorkspacePath !== null) { + throw new WorkspaceActivationInProgressError(); + } +} diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts index 03298489a..253e5f4ee 100644 --- a/apps/server/src/modules/workspaces.route.test.ts +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -26,7 +26,9 @@ function handleOf({ workspaceId, name }: TestMember): TestHandle { const testState = vi.hoisted(() => ({ managed: false, active: null as TestHandle | null, + activePath: null as string | null, members: [] as TestMember[], + diskIdentities: [] as TestMember[], })); const storageMocks = vi.hoisted(() => ({ @@ -43,10 +45,10 @@ const preprocessingMocks = vi.hoisted(() => ({ })); const repository = vi.hoisted(() => ({ - list: vi.fn(() => + list: vi.fn(async () => testState.members.map(({ workspaceId, name }) => ({ workspaceId, name })), ), - get: vi.fn((workspaceId: string) => { + get: vi.fn(async (workspaceId: string) => { const member = testState.members.find( (candidate) => candidate.workspaceId === workspaceId, ); @@ -54,7 +56,7 @@ const repository = vi.hoisted(() => ({ ? { workspaceId: member.workspaceId, name: member.name } : null; }), - rename: vi.fn((workspaceId: string, name: string) => { + rename: vi.fn(async (workspaceId: string, name: string) => { const index = testState.members.findIndex( (candidate) => candidate.workspaceId === workspaceId, ); @@ -63,7 +65,7 @@ const repository = vi.hoisted(() => ({ testState.members[index] = member; return { workspaceId: member.workspaceId, name: member.name }; }), - remove: vi.fn((workspaceId: string) => { + remove: vi.fn(async (workspaceId: string) => { const index = testState.members.findIndex( (candidate) => candidate.workspaceId === workspaceId, ); @@ -89,12 +91,32 @@ const locatorMocks = vi.hoisted(() => ({ ? { workspaceId: member.workspaceId, name: member.name } : null; }), + ensureWorkspaceManifestOnDisk: vi.fn((workspacePath: string) => { + let member = testState.diskIdentities.find( + (candidate) => candidate.workspacePath === workspacePath, + ); + if (!member) { + member = { + workspaceId: NEW_ID, + workspacePath, + name: workspacePath.split('/').filter(Boolean).at(-1) ?? 'Workspace', + }; + testState.diskIdentities.push(member); + } + return { schemaVersion: 1, ...handleOf(member) }; + }), adoptWorkspaceDirectory: vi.fn((workspacePath: string) => { + const identity = locatorMocks.ensureWorkspaceManifestOnDisk(workspacePath); const member: TestMember = { - workspaceId: '00000000-0000-4000-8000-000000000003', + workspaceId: identity.workspaceId, + name: identity.name, workspacePath, - name: workspacePath.split('/').filter(Boolean).at(-1) ?? 'Workspace', }; + testState.members = testState.members.filter( + (candidate) => + candidate.workspaceId !== member.workspaceId && + candidate.workspacePath !== workspacePath, + ); testState.members.push(member); return { workspaceId: member.workspaceId, name: member.name }; }), @@ -104,12 +126,21 @@ vi.mock('./storage/index.js', () => ({ getWorkspaceRepository: () => repository, resetStorageCache: storageMocks.resetStorageCache, adoptWorkspaceDirectory: locatorMocks.adoptWorkspaceDirectory, + ensureWorkspaceManifestOnDisk: locatorMocks.ensureWorkspaceManifestOnDisk, workspaceAtDirectory: locatorMocks.workspaceAtDirectory, workspaceDirectory: locatorMocks.workspaceDirectory, })); vi.mock('./workspace.js', () => ({ + commitWorkspacePath: (workspacePath: string) => { + testState.active = locatorMocks.adoptWorkspaceDirectory(workspacePath); + testState.activePath = workspacePath; + }, getWorkspaceHandle: () => testState.active, + getWorkspacePath: () => { + if (!testState.activePath) throw new Error('No active Workspace path'); + return testState.activePath; + }, isManagedMode: () => testState.managed, resolveWorkspacePath: (workspacePath: string) => workspacePath, updateActiveWorkspaceHandle: (workspace: TestHandle) => { @@ -157,12 +188,15 @@ beforeEach(() => { name: 'Second', }, ]; + testState.diskIdentities = testState.members.map((member) => ({ ...member })); const first = testState.members[0]; testState.active = first ? handleOf(first) : null; + testState.activePath = first?.workspacePath ?? null; vi.clearAllMocks(); activationMocks.prepareWorkspacePath.mockImplementation(async (path) => path); activationMocks.activateWorkspacePath.mockImplementation(async (path) => { testState.active = locatorMocks.workspaceAtDirectory(path); + testState.activePath = path; }); }); @@ -217,6 +251,45 @@ describe('plural Workspace management routes', () => { } }); + it('follows an externally moved active Workspace without splitting its path', async () => { + testState.diskIdentities = [ + { + workspaceId: FIRST_ID, + workspacePath: '/tmp/moved-first', + name: 'First', + }, + testState.diskIdentities[1] as TestMember, + ]; + const app = await buildApp(); + try { + const response = await app.inject({ + method: 'POST', + url: '/workspaces', + payload: { path: '/tmp/moved-first' }, + }); + + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ + workspaceId: FIRST_ID, + name: 'First', + path: '/tmp/moved-first', + active: true, + }); + expect(testState.activePath).toBe('/tmp/moved-first'); + expect( + testState.members.filter((member) => member.workspaceId === FIRST_ID), + ).toEqual([ + { + workspaceId: FIRST_ID, + workspacePath: '/tmp/moved-first', + name: 'First', + }, + ]); + } finally { + await app.close(); + } + }); + it('activates a registered Workspace by stable id', async () => { const app = await buildApp(); try { @@ -277,7 +350,7 @@ describe('plural Workspace management routes', () => { url: `/workspaces/${SECOND_ID}`, }); expect(inactiveResponse.statusCode).toBe(204); - expect(repository.get(SECOND_ID)).toBeNull(); + await expect(repository.get(SECOND_ID)).resolves.toBeNull(); } finally { await app.close(); } diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index 1c70d6c39..d0574c2a6 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import path from 'node:path'; + import { z } from 'zod'; import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; @@ -8,6 +10,7 @@ import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; import { resetPreprocessDispatcher } from './preprocessing/index.js'; import { adoptWorkspaceDirectory, + ensureWorkspaceManifestOnDisk, getWorkspaceRepository, resetStorageCache, workspaceAtDirectory, @@ -20,7 +23,9 @@ import { WorkspaceActivationTimeoutError, } from './workspace-activation.js'; import { + commitWorkspacePath, getWorkspaceHandle, + getWorkspacePath, isManagedMode, resolveWorkspacePath, updateActiveWorkspaceHandle, @@ -74,17 +79,37 @@ function rejectReadOnlyMutation( } function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { - const active = getWorkspaceHandle()?.workspaceId === workspace.workspaceId; + const workspacePath = workspaceDirectory(workspace.workspaceId); + const activeHandle = getWorkspaceHandle(); + const active = + activeHandle?.workspaceId === workspace.workspaceId && + workspacePath !== null && + path.resolve(getWorkspacePath()) === path.resolve(workspacePath); return { workspaceId: workspace.workspaceId, name: workspace.name, // A Workspace's directory is a materialization fact the handle does not // carry, so it is resolved separately — and never sent in managed mode. - path: isManagedMode() ? null : workspaceDirectory(workspace.workspaceId), + path: isManagedMode() ? null : workspacePath, active, }; } +/** Follow an externally moved active Disk Workspace before publishing it. */ +function reconcileActiveWorkspaceLocation( + workspace: WorkspaceHandle, + workspacePath: string, +): WorkspaceHandle { + if (getWorkspaceHandle()?.workspaceId !== workspace.workspaceId) { + return workspace; + } + if (path.resolve(getWorkspacePath()) === path.resolve(workspacePath)) { + return workspace; + } + commitWorkspacePath(workspacePath); + return getWorkspaceHandle() ?? workspace; +} + /** * The Workspaces this deployment may talk about. * @@ -95,14 +120,16 @@ function descriptor(workspace: WorkspaceHandle): WorkspaceDescriptor { * deployment cannot reach, which is the very thing path redaction exists to * prevent. Managed mode therefore sees exactly one Workspace: the active one. */ -function visibleWorkspaces(): readonly WorkspaceHandle[] { - if (!isManagedMode()) return getWorkspaceRepository().list(); +async function visibleWorkspaces(): Promise { + if (!isManagedMode()) return await getWorkspaceRepository().list(); const active = getWorkspaceHandle(); return active ? [active] : []; } -function findVisible(workspaceId: string): WorkspaceHandle | null { - if (!isManagedMode()) return getWorkspaceRepository().get(workspaceId); +async function findVisible( + workspaceId: string, +): Promise { + if (!isManagedMode()) return await getWorkspaceRepository().get(workspaceId); const active = getWorkspaceHandle(); return active?.workspaceId === workspaceId ? active : null; } @@ -147,7 +174,7 @@ interface WorkspaceParams { } const workspacesRoutes: FastifyPluginAsync = async (app) => { - app.get('/', async () => visibleWorkspaces().map(descriptor)); + app.get('/', async () => (await visibleWorkspaces()).map(descriptor)); app.post<{ Body: WorkspaceCreateRequest }>('/', async (request, reply) => { const rejected = rejectReadOnlyMutation(request, reply); @@ -167,19 +194,39 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { const repository = getWorkspaceRepository(); const existing = workspaceAtDirectory(workspacePath); if (existing) { - if (!parsed.data.name) return reply.send(descriptor(existing)); + const current = reconcileActiveWorkspaceLocation( + existing, + workspacePath, + ); + if (!parsed.data.name) return reply.send(descriptor(current)); const renamed = - repository.rename(existing.workspaceId, parsed.data.name) ?? existing; + (await repository.rename(existing.workspaceId, parsed.data.name)) ?? + current; updateActiveWorkspaceHandle(renamed); return reply.send(descriptor(renamed)); } await prepareWorkspacePath(workspacePath); - let workspace = adoptWorkspaceDirectory(workspacePath); + const preparedManifest = ensureWorkspaceManifestOnDisk(workspacePath); + const preparedWorkspace = { + workspaceId: preparedManifest.workspaceId, + name: preparedManifest.name, + }; + let workspace: WorkspaceHandle; + if (getWorkspaceHandle()?.workspaceId === preparedWorkspace.workspaceId) { + // `existing` was null, so even a same-path active Workspace needs its + // missing membership repaired. Committing adopts it and keeps active + // identity and materialization on one location. + commitWorkspacePath(workspacePath); + workspace = getWorkspaceHandle() ?? preparedWorkspace; + } else { + workspace = adoptWorkspaceDirectory(workspacePath); + } if (parsed.data.name) { workspace = - repository.rename(workspace.workspaceId, parsed.data.name) ?? + (await repository.rename(workspace.workspaceId, parsed.data.name)) ?? workspace; + updateActiveWorkspaceHandle(workspace); } return reply.status(201).send(descriptor(workspace)); } catch (error) { @@ -192,7 +239,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { async (request, reply) => { const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; - const workspace = findVisible(parsedId); + const workspace = await findVisible(parsedId); if (!workspace) return sendError(reply, 404, 'Workspace not found'); return reply.send(descriptor(workspace)); }, @@ -206,7 +253,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { const parsedId = parseWorkspaceId(request.params.workspaceId, reply); if (typeof parsedId !== 'string') return parsedId; - const workspace = getWorkspaceRepository().get(parsedId); + const workspace = await getWorkspaceRepository().get(parsedId); const workspacePath = workspaceDirectory(parsedId); if (!workspace || !workspacePath) { return sendError(reply, 404, 'Workspace not found'); @@ -239,7 +286,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { } try { - const workspace = getWorkspaceRepository().rename( + const workspace = await getWorkspaceRepository().rename( parsedId, parsed.data.name, ); @@ -265,7 +312,7 @@ const workspacesRoutes: FastifyPluginAsync = async (app) => { // Deliberately not gated on the Workspace being readable: unregistering // a folder that has since been deleted or unmounted is exactly when // this is needed, and it only ever removes the index entry. - if (!getWorkspaceRepository().remove(parsedId)) { + if (!(await getWorkspaceRepository().remove(parsedId))) { return sendError(reply, 404, 'Workspace not found'); } return reply.status(204).send(); diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 98782d0d0..104ea7335 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -1,12 +1,12 @@ # Canvas Storage Architecture -> Last updated: 2026-08-22 +> Last updated: 2026-08-24 ## 1. Overview Every Space remains fully self-contained on Disk by default, but storage no longer presents one all-purpose `CanvasStore` as its backend contract. `apps/server/src/modules/storage/` separates backend-neutral blob and structured ports, Disk adapters, process-wide composition, and a shrinking Disk compatibility facade. Opaque artifact bytes flow through `BlobStore`; `StructuredStore` exposes one `spaces()` repository for the Space collection — membership, World identity, and create/delete/rename — while `SpaceHandle` exposes the Space's own async record read and ordered write plus the parts it holds: `nodes`, `changes`, `tasks`, and `events`. Space creation and deletion, standalone node writes, executor and revert batches, preprocessing persistence, and event/change mutations enter these ports. The remaining compatibility consumers are explicit Disk capabilities and paths such as ZIP import/export, RFS upload/delete, external-note observation/claim, bootstrap/migration, and hydration helpers; some read and some mutate physical files, so they keep non-Disk profiles unselectable until their own contracts are designed. -Runtime Home-folder activation prepares and migrates the selected directory in a disposable child process before committing it as the active workspace. This isolation is required because synchronous filesystem calls against cloud, network, or virtual drives can block indefinitely; a stuck preparation is terminated after 70 seconds with `WORKSPACE_ACTIVATION_TIMEOUT`, while the Server event loop and previously active workspace remain available. Concurrent activation attempts return `WORKSPACE_ACTIVATION_IN_PROGRESS`. Managed-mode startup still prepares synchronously before the Server accepts requests. +Runtime Home-folder activation reserves the namespace switch before preparing and migrating the selected directory in a disposable child process. An in-flight Workspace operation therefore refuses the switch before the target is touched, and no new operation can enter the old Workspace while preparation is pending. This isolation is required because synchronous filesystem calls against cloud, network, or virtual drives can block indefinitely; a stuck preparation is terminated after 70 seconds with `WORKSPACE_ACTIVATION_TIMEOUT`, while the Server event loop and previously active workspace remain available. Concurrent activation attempts return `WORKSPACE_ACTIVATION_IN_PROGRESS`. Managed-mode startup still prepares synchronously before the Server accepts requests. ## 2. Disk Layout @@ -46,9 +46,9 @@ Runtime Home-folder activation prepares and migrates the selected directory in a Key points: - `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. -- Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. Two live paths carrying the same id are rejected as a copied-identity conflict; a path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. +- Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. If it is active, the process-local active path moves with that registration before the API publishes the new location. Two physically distinct live paths carrying the same id are rejected as a copied-identity conflict; symlink aliases of one directory are the same materialization, not copies. A path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. - A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest. -- Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. Managed mode adopts its Workspace while `app.ts` is still evaluating, before the boot sequence can await `initStorage()`; 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. +- Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the async Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. The configured backend connections are process-wide: selecting another Workspace changes the active namespace inside those existing connections and does not drop or reconnect them. A SQL adapter therefore holds all Workspace membership and data behind one live connection/pool, with Workspace ids scoping repository and handle operations. Managed-mode Disk adoption still happens while `app.ts` is evaluating; a connection-backed adapter wires its repository during the awaited storage startup. - `WorkspaceHandle` 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. The locator is the Workspace-level twin of `spaceDirectory()` and resolves in composition — `adoptWorkspaceDirectory()`, `workspaceAtDirectory()`, `workspaceDirectory()` — where a non-materializing profile refuses outright. `workspace.ts` therefore holds the active identity and the active path as two separate facts. - The manifest schema is the single definition of a valid manifest and guards the write as well as the read, so a caller cannot persist a name that would fail validation on the next read. - Managed deployments expose exactly one Workspace — the active one. 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. diff --git a/docs/proposals/multi-backend-storage.md b/docs/proposals/multi-backend-storage.md index b787dd494..d8b150c3d 100644 --- a/docs/proposals/multi-backend-storage.md +++ b/docs/proposals/multi-backend-storage.md @@ -90,7 +90,7 @@ built above these ports, but its form is intentionally unresolved here. | Node Markdown ownership | **Accepted** (P4) | Authored node content remains with structured node records because it participates in revision CAS, search, and node mutation. Opaque and large bytes remain in BlobStore. | | Blob key, staging, deletion, and GC semantics | Proposed / open | Names are the existing `` keys; `deleteAll()` covers Space destruction. Staging, reference counting, and GC remain undesigned. Per-key deletion stays out of the public port, but the absence of any cleanup path is what makes atomic replace mandatory (§6.2). | | Space-handle identity and caching | **Corrected** (P1) | `space(id)` returning a stable handle is bounded by the LRU behind it, not guaranteed. In-memory tombstones and the filename index are therefore adapter-local caches, never durable state (§12.1.1, §12.2.4). | -| Backend selection scope | Open | Process-global today because the profile is read from env. Per-Workspace or per-Space selection has not been fixed. | +| Backend selection scope | **Accepted** | Backend selection and its connection/pool are process-global. Workspaces are namespaces inside the configured backend; activating another Workspace re-scopes repository/handle operations without dropping or reconnecting the backend. A SQL profile serves every Workspace through one live connection/pool. | | Logical filesystem view | Open | A possible `SpaceFileView` above both stores; name and contract are not accepted yet. | | Real agent workspace | Open | Materialized directory, OS mount, protocol-only access, or a combination remain under evaluation. | | Agent-authored filesystem write-back | Open | Read-only projection, explicit checkout/commit, and live bidirectional sync are alternatives, not decisions. | @@ -303,9 +303,13 @@ interface StorageProfile { ``` Parsed from `HUABU_STRUCTURED_BACKEND` and `HUABU_BLOB_BACKEND`, both -defaulting to `disk`. Credential references, selection scope, config storage, -restart behavior, and runtime switching remain open — a Postgres DSN or Azure -container reference will extend these members. +defaulting to `disk`. Backend selection is process-global and initialization +opens one connection or pool for each configured axis. Workspace activation +selects a namespace inside those existing connections; it does not rebuild +them, and a SQL structured adapter serves every Workspace through the same +connection/pool. Credential references, config storage, and deployment-level +backend migration remain open — a Postgres DSN or Azure container reference +will extend these members. Some combinations require capability validation. For example, Postgres plus a node-local DiskBlob implementation is unsafe in a multi-replica deployment @@ -1999,9 +2003,6 @@ resume point such a digest would need. ### Structured storage -- Is backend selection global, per Workspace, or per Space? (Process-global - today only because the profile is read from env — that is an implementation - default, not a decision.) - Which Canvas-owned records belong in each L1 repository while preserving Agenetes ownership of Thread/Event/Turn semantics and ports? - Does `StructuredStore` remain only a name for the configured backend family, @@ -2045,12 +2046,11 @@ resume point such a digest would need. ### Composition and migration - Which backend combinations are supported product configurations? -- What happens to open connections on a free-mode Workspace switch? Today the - switch resets the Space-instance cache but never rebuilds the storage - holder, which is invisible only because both Disk adapters are stateless and - resolve the workspace path per call. -- Can a Workspace change either backend after creation, and is migration - online or offline? +- Which Workspace-scoped handles and caches must be invalidated on activation? + The process-wide backend connections remain open; SQL Workspaces share one + connection/pool and activation only changes the selected namespace. +- Can a deployment change either configured backend, and is migration online + or offline? - How are backups and restores made consistent across structured and blob stores? - How are health, readiness, degraded operation, and observability reported? From 398a68a33ed3af42910fcdec8bfa01a695534c51 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 26 Aug 2026 14:08:48 +0800 Subject: [PATCH 8/9] feat: migrate desktop workspace history to timestamped registry --- apps/desktop/src/main.ts | 134 +------- apps/desktop/src/preload.ts | 28 -- .../legacy-desktop-workspace-store.test.ts | 126 +++++++ .../modules/legacy-desktop-workspace-store.ts | 90 +++++ .../disk/workspace-repository.test.ts | 66 +++- .../backends/disk/workspace-repository.ts | 71 ++-- apps/server/src/modules/storage/index.ts | 1 + apps/server/src/modules/storage/storage.ts | 5 + .../src/modules/workspaces.route.test.ts | 44 ++- apps/server/src/modules/workspaces.route.ts | 18 + apps/web/src/api/_routes.ts | 4 + apps/web/src/api/workspace.ts | 27 ++ apps/web/src/hooks/useElectron.ts | 20 -- apps/web/src/pages/WorkspaceSetupPage.tsx | 57 +++- apps/web/src/store/workspaceStore.test.ts | 148 ++++++++ apps/web/src/store/workspaceStore.ts | 315 +++++++----------- docs/architecture/canvas-storage.md | 5 +- docs/architecture/web-architecture.md | 2 +- 18 files changed, 740 insertions(+), 421 deletions(-) create mode 100644 apps/server/src/modules/legacy-desktop-workspace-store.test.ts create mode 100644 apps/server/src/modules/legacy-desktop-workspace-store.ts create mode 100644 apps/web/src/store/workspaceStore.test.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 13e5a43d7..1f3bd7575 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -32,8 +32,6 @@ import { existsSync, mkdirSync, readdirSync, - readFileSync, - renameSync, statSync, unlinkSync, writeFileSync, @@ -83,7 +81,7 @@ const IS_DEV_ORCHESTRATOR = Boolean(process.env.EXTERNAL_SERVER_URL?.trim()); * * - Packaged install / `start:desktop` → always `Huabu`. A single global * instance is intentional: these share one `/data` tree, so - * two of them at once fight over port 3001 and the same `workspace.json` + * two of them at once fight over port 3001 and the same Workspace registry * (the exact failure the single-instance lock guards against). * * - `dev:desktop` (HMR orchestrator) → `Huabu Dev`, OPTIONALLY suffixed @@ -112,8 +110,8 @@ function resolveAppName(): string { * * Only the HMR dev orchestrator (`pnpm dev:desktop`) gets a different name. * Its tsx-watch server and Vite HMR are actively-changing code, so we keep - * its `workspace.json`, Chromium storage, and Electron logs / crash dumps - * isolated from a real install. (Its LLM/integration secrets are a separate + * its Chromium storage and Electron logs / crash dumps isolated from a real + * install. (Its LLM/integration secrets are a separate * concern that's ALSO isolated, but not by this name split: with * `EXTERNAL_SERVER_URL` set we skip the `safeStorage`-backed * `DesktopSecureSecretStore` below entirely, and the tsx-watch server @@ -123,7 +121,7 @@ function resolveAppName(): string { * `pnpm start:desktop`, by contrast, runs the exact same bundled server / * web build a packaged install would run — it's typically used as a final * smoke test before shipping, so it intentionally shares `Huabu`'s on-disk - * state with the installed app: same workspace, and the same + * state with the installed app: same Workspace registry, and the same * `safeStorage`-encrypted `/data/secure-secrets.json` (so secrets * already configured in the installed app are reused) rather than starting * from an empty slate. @@ -152,7 +150,7 @@ app.setName(resolveAppName()); * a second time, or running `start:desktop` while the installed app is * open) forks a SECOND Fastify server. The two servers then fight over * the preferred port (3001) and, worse, share the same - * `/data` tree — same `workspace.json`, same canvas DB. When + * `/data` tree — same Workspace registry, same canvas DB. When * one instance later shuts its server down, any window still pointed at * `127.0.0.1:3001` starts getting `503 (server closing)` and then * `ERR_CONNECTION_REFUSED`. @@ -457,8 +455,8 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv { // Ensure the data directory exists so the server doesn't have to // race-condition on first-use creation. The workspace directory is // intentionally NOT pre-created: in free mode the user picks it via - // the in-app UI (folder picker / path input), and the web client - // persists the selection across launches via localStorage. + // the in-app UI (folder picker / path input), and the Server records the + // selection in its Workspace registry after successful activation. mkdirSync(dataDir, { recursive: true }); if (IS_DEV && webDistPath && !existsSync(webDistPath)) { @@ -485,6 +483,9 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv { SERVER_PORT: String(port), HUABU_BIND_HOST: '127.0.0.1', HUABU_DATA_DIR: dataDir, + // Read-only upgrade source. The Server imports this deprecated file only + // when its authoritative storage/disk/workspaces.json does not exist. + HUABU_LEGACY_WORKSPACE_STORE: join(userData, 'workspace.json'), HUABU_SECRET_BRIDGE: '1', ...(webDistPath ? { WEB_DIST_PATH: webDistPath } : {}), NODE_ENV: IS_DEV ? 'development' : 'production', @@ -691,115 +692,6 @@ function waitForPort( }); } -// ── Workspace persistence ──────────────────────────────────────────── - -/** - * Persist the user-selected free-mode workspace path (and recent list) - * in a JSON file under `app.getPath('userData')` so it survives across - * launches independently of the renderer's `localStorage`. - * - * Why not just rely on `localStorage`? Chromium partitions storage by - * origin (scheme + host + port). The Electron shell forks the server on - * a fresh port whenever the preferred port (3001) is busy — e.g. a - * leftover server process, another local service, or simply a second - * launch racing with the first. A different port means a different - * origin, which means a separate, empty `localStorage` bucket and the - * user is dumped back on the workspace picker even though they picked - * a folder yesterday. - * - * Storing the path in the main process (one location per user, - * port-agnostic) and exposing it over IPC sidesteps the partition - * entirely. The renderer still keeps `localStorage` writes for - * browser/dev mode compatibility, but the Electron bridge takes - * precedence when present. - */ - -const WORKSPACE_STORE_FILE = 'workspace.json'; -const MAX_RECENT_WORKSPACES = 5; - -interface WorkspaceStore { - path: string | null; - recent: string[]; -} - -function workspaceStorePath(): string { - return join(app.getPath('userData'), WORKSPACE_STORE_FILE); -} - -function readWorkspaceStore(): WorkspaceStore { - const file = workspaceStorePath(); - if (!existsSync(file)) return { path: null, recent: [] }; - try { - const raw = readFileSync(file, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') { - return { path: null, recent: [] }; - } - const obj = parsed as Record; - const path = - typeof obj.path === 'string' && obj.path.length > 0 ? obj.path : null; - const recent = Array.isArray(obj.recent) - ? obj.recent.filter((p): p is string => typeof p === 'string') - : []; - return { path, recent }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.warn(`[desktop] workspace store unreadable: ${message}`); - return { path: null, recent: [] }; - } -} - -function writeWorkspaceStore(store: WorkspaceStore): void { - const file = workspaceStorePath(); - // Atomic-ish write via tmp + rename so a crash mid-write doesn't - // leave a half-truncated JSON the next launch refuses to parse. - const tmp = `${file}.tmp`; - mkdirSync(app.getPath('userData'), { recursive: true }); - writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf8'); - renameSync(tmp, file); -} - -function pushRecentWorkspace(store: WorkspaceStore, path: string): string[] { - const next = [path, ...store.recent.filter((p) => p !== path)].slice( - 0, - MAX_RECENT_WORKSPACES, - ); - return next; -} - -function registerWorkspaceIpc(): void { - ipcMain.handle('workspace:get', () => readWorkspaceStore()); - - ipcMain.handle('workspace:set', (_event, rawPath: unknown) => { - if (typeof rawPath !== 'string' || rawPath.length === 0) { - throw new Error('workspace:set requires a non-empty string path'); - } - if (!isAbsolute(rawPath)) { - throw new Error('workspace:set requires an absolute path'); - } - const current = readWorkspaceStore(); - const next: WorkspaceStore = { - path: rawPath, - recent: pushRecentWorkspace(current, rawPath), - }; - writeWorkspaceStore(next); - return next; - }); - - ipcMain.handle('workspace:remove-recent', (_event, rawPath: unknown) => { - if (typeof rawPath !== 'string') { - throw new Error('workspace:remove-recent requires a string path'); - } - const current = readWorkspaceStore(); - const next: WorkspaceStore = { - path: current.path === rawPath ? null : current.path, - recent: current.recent.filter((p) => p !== rawPath), - }; - writeWorkspaceStore(next); - return next; - }); -} - function registerWindowIpc(): void { ipcMain.handle('window:is-fullscreen', () => { return mainWindow ? mainWindow.isFullScreen() : false; @@ -1241,10 +1133,8 @@ app.whenReady().then(async () => { // inside `createWindow`. applyApplicationMenu(() => mainWindow); - // Register IPC handlers BEFORE any window is created so the preload - // script's `ipcRenderer.invoke('workspace:get', …)` calls always have - // a handler to talk to, even on the very first render. - registerWorkspaceIpc(); + // Register IPC handlers before any window is created so every preload + // bridge is ready on the first render. registerWindowIpc(); registerDiagnosticsIpc(); registerDialogIpc(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c3efef445..400f36cbd 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -39,11 +39,6 @@ import { contextBridge, ipcRenderer } from 'electron'; */ const TITLE_BAR_HEIGHT = 36; -interface WorkspaceStoreSnapshot { - path: string | null; - recent: string[]; -} - contextBridge.exposeInMainWorld('electronBridge', { versions: { node: process.versions.node, @@ -66,29 +61,6 @@ contextBridge.exposeInMainWorld('electronBridge', { * number on its own side. */ titleBarHeight: TITLE_BAR_HEIGHT, - /** - * Port-agnostic workspace persistence. The main process writes the - * selected free-mode workspace path (and its recents list) into - * `/workspace.json`, sidestepping the per-origin - * `localStorage` bucket that resets whenever Electron has to pick a - * fresh server port. See `main.ts` → "Workspace persistence" for - * the full rationale. - */ - workspace: { - get: (): Promise => - ipcRenderer.invoke('workspace:get') as Promise, - set: (path: string): Promise => - ipcRenderer.invoke( - 'workspace:set', - path, - ) as Promise, - removeRecent: (path: string): Promise => - ipcRenderer.invoke( - 'workspace:remove-recent', - path, - ) as Promise, - }, - window: { isFullScreen: (): Promise => ipcRenderer.invoke('window:is-fullscreen') as Promise, diff --git a/apps/server/src/modules/legacy-desktop-workspace-store.test.ts b/apps/server/src/modules/legacy-desktop-workspace-store.test.ts new file mode 100644 index 000000000..2deb707eb --- /dev/null +++ b/apps/server/src/modules/legacy-desktop-workspace-store.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { migrateLegacyDesktopWorkspaceStore } from './legacy-desktop-workspace-store.js'; +import { + DiskWorkspaceRepository, + WORKSPACE_MANIFEST_FILENAME, +} from './storage/backends/disk/workspace-repository.js'; + +describe('deprecated desktop Workspace store migration', () => { + const roots: string[] = []; + + function tempDir(prefix: string): string { + const root = mkdtempSync(path.join(tmpdir(), prefix)); + roots.push(root); + return root; + } + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('imports the legacy active path and recents in MRU order', async () => { + const dataDir = tempDir('huabu-legacy-store-data-'); + const first = tempDir('huabu-legacy-store-first-'); + const second = tempDir('huabu-legacy-store-second-'); + const legacyFile = path.join(dataDir, 'workspace.json'); + const registryFile = path.join( + dataDir, + 'data', + 'storage', + 'disk', + 'workspaces.json', + ); + writeFileSync( + legacyFile, + JSON.stringify({ path: second, recent: [second, first] }), + 'utf8', + ); + const repository = new DiskWorkspaceRepository(registryFile); + const prepareWorkspacePath = vi.fn(async (workspacePath: string) => + path.resolve(workspacePath), + ); + + await migrateLegacyDesktopWorkspaceStore(legacyFile, { + hasWorkspaceRegistry: () => repository.hasDurableRegistry(), + prepareWorkspacePath, + adoptWorkspaceDirectory: (workspacePath) => + repository.adopt(workspacePath), + }); + + expect( + prepareWorkspacePath.mock.calls.map(([workspacePath]) => workspacePath), + ).toEqual([first, second]); + const listed = await repository.list(); + expect( + listed.map((workspace) => repository.directoryOf(workspace.workspaceId)), + ).toEqual([path.resolve(second), path.resolve(first)]); + expect( + readFileSync(path.join(first, WORKSPACE_MANIFEST_FILENAME), 'utf8'), + ).toContain('workspaceId'); + }); + + it('ignores the deprecated file once workspaces.json exists', async () => { + const dataDir = tempDir('huabu-legacy-store-existing-data-'); + const existing = tempDir('huabu-legacy-store-existing-'); + const legacy = tempDir('huabu-legacy-store-ignored-'); + const legacyFile = path.join(dataDir, 'workspace.json'); + const registryFile = path.join(dataDir, 'workspaces.json'); + writeFileSync( + legacyFile, + JSON.stringify({ path: legacy, recent: [legacy] }), + 'utf8', + ); + const repository = new DiskWorkspaceRepository(registryFile); + const registered = repository.adopt(existing); + const prepareWorkspacePath = vi.fn( + async (workspacePath: string) => workspacePath, + ); + + await expect( + migrateLegacyDesktopWorkspaceStore(legacyFile, { + hasWorkspaceRegistry: () => repository.hasDurableRegistry(), + prepareWorkspacePath, + adoptWorkspaceDirectory: (workspacePath) => + repository.adopt(workspacePath), + }), + ).resolves.toBeUndefined(); + + expect(prepareWorkspacePath).not.toHaveBeenCalled(); + await expect(repository.list()).resolves.toEqual([registered]); + }); + + it('leaves the registry absent when no legacy entry can be migrated', async () => { + const dataDir = tempDir('huabu-legacy-store-empty-data-'); + const legacyFile = path.join(dataDir, 'workspace.json'); + writeFileSync( + legacyFile, + JSON.stringify({ path: '/unavailable', recent: ['/unavailable'] }), + 'utf8', + ); + const repository = new DiskWorkspaceRepository( + path.join(dataDir, 'storage', 'disk', 'workspaces.json'), + ); + + await migrateLegacyDesktopWorkspaceStore(legacyFile, { + hasWorkspaceRegistry: () => repository.hasDurableRegistry(), + prepareWorkspacePath: async () => { + throw new Error('unavailable'); + }, + adoptWorkspaceDirectory: (workspacePath) => + repository.adopt(workspacePath), + }); + + expect(repository.hasDurableRegistry()).toBe(false); + await expect(repository.list()).resolves.toEqual([]); + }); +}); diff --git a/apps/server/src/modules/legacy-desktop-workspace-store.ts b/apps/server/src/modules/legacy-desktop-workspace-store.ts new file mode 100644 index 000000000..a67c41d24 --- /dev/null +++ b/apps/server/src/modules/legacy-desktop-workspace-store.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Upgrade migration from Electron's deprecated `/workspace.json`. + * + * The plural Workspace registry is now the sole durable owner of membership, + * active-path restoration, and recency ordering. Desktop still tells the + * Server where the old file lived so an upgrade can import it, but only while + * `workspaces.json` does not exist. Once any entry creates the new registry, + * the legacy file is never consulted again. + */ + +import { readFileSync } from 'node:fs'; + +import { getLogger } from '../utils/logger.js'; + +const MAX_LEGACY_WORKSPACES = 6; +const log = getLogger('legacy-desktop-workspace-store'); + +export interface LegacyWorkspaceMigrationDependencies { + hasWorkspaceRegistry: () => boolean; + prepareWorkspacePath: (workspacePath: string) => Promise; + adoptWorkspaceDirectory: (workspacePath: string) => void; +} + +function legacyWorkspacePaths(raw: unknown): string[] { + if (!raw || typeof raw !== 'object') return []; + const store = raw as Record; + const candidates = [ + ...(typeof store.path === 'string' ? [store.path] : []), + ...(Array.isArray(store.recent) ? store.recent : []), + ]; + const paths: string[] = []; + for (const candidate of candidates) { + if (typeof candidate !== 'string' || candidate.length === 0) continue; + if (!paths.includes(candidate)) paths.push(candidate); + if (paths.length >= MAX_LEGACY_WORKSPACES) break; + } + return paths; +} + +export async function migrateLegacyDesktopWorkspaceStore( + filePath: string, + dependencies: LegacyWorkspaceMigrationDependencies, +): Promise { + if (dependencies.hasWorkspaceRegistry()) return; + + let paths: string[] = []; + try { + paths = legacyWorkspacePaths( + JSON.parse(readFileSync(filePath, 'utf8')) as unknown, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== 'ENOENT') { + log.warn( + { error, filePath }, + 'Deprecated desktop Workspace store could not be read; continuing without legacy entries', + ); + } + } + + let migrated = 0; + let skipped = 0; + + // `adopt()` records the current time, so import oldest-to-newest to preserve + // the legacy file's existing most-recent-first order. + for (const workspacePath of [...paths].reverse()) { + try { + const preparedPath = + await dependencies.prepareWorkspacePath(workspacePath); + dependencies.adoptWorkspaceDirectory(preparedPath); + migrated += 1; + } catch (error) { + skipped += 1; + log.warn( + { error, workspacePath }, + 'Deprecated desktop Workspace entry could not be migrated', + ); + } + } + + if (paths.length > 0) { + log.info( + { filePath, migrated, skipped }, + 'Processed deprecated desktop Workspace store', + ); + } +} diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts index 5a5ee936b..7fae730b9 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.test.ts @@ -44,6 +44,8 @@ describe('DiskWorkspaceRepository', () => { } }); + afterEach(() => vi.restoreAllMocks()); + describeWorkspaceRepositoryContract('Disk', async () => { const repository = new DiskWorkspaceRepository(); return { @@ -98,10 +100,65 @@ describe('DiskWorkspaceRepository', () => { expect(repository.directoryOf(first.workspaceId)).toBe( path.resolve(firstRoot), ); - await expect(repository.list()).resolves.toEqual([first, second]); + await expect(repository.list()).resolves.toEqual([second, first]); + }); + + it('persists recency as timestamps instead of array order', async () => { + const firstOpenedAt = Date.parse('2026-08-26T01:00:00.000Z'); + const secondOpenedAt = Date.parse('2026-08-26T02:00:00.000Z'); + const reopenedAt = Date.parse('2026-08-26T03:00:00.000Z'); + vi.spyOn(Date, 'now') + .mockReturnValueOnce(firstOpenedAt) + .mockReturnValueOnce(secondOpenedAt) + .mockReturnValue(reopenedAt); + const filePath = registryPath(tempDir('huabu-workspace-timestamp-data-')); + const repository = new DiskWorkspaceRepository(filePath); + const firstRoot = tempDir('huabu-workspace-timestamp-first-'); + const secondRoot = tempDir('huabu-workspace-timestamp-second-'); + + const first = repository.adopt(firstRoot); + const second = repository.adopt(secondRoot); + + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ + schemaVersion: 1, + workspaces: [ + { + workspaceId: first.workspaceId, + workspacePath: path.resolve(firstRoot), + lastOpenedAt: new Date(firstOpenedAt).toISOString(), + }, + { + workspaceId: second.workspaceId, + workspacePath: path.resolve(secondRoot), + lastOpenedAt: new Date(secondOpenedAt).toISOString(), + }, + ], + }); + await expect(repository.list()).resolves.toEqual([second, first]); + + repository.adopt(firstRoot); + + expect(JSON.parse(readFileSync(filePath, 'utf8'))).toEqual({ + schemaVersion: 1, + workspaces: [ + { + workspaceId: first.workspaceId, + workspacePath: path.resolve(firstRoot), + lastOpenedAt: new Date(reopenedAt).toISOString(), + }, + { + workspaceId: second.workspaceId, + workspacePath: path.resolve(secondRoot), + lastOpenedAt: new Date(secondOpenedAt).toISOString(), + }, + ], + }); + await expect(new DiskWorkspaceRepository(filePath).list()).resolves.toEqual( + [first, second], + ); }); - it('persists only the stable id-to-path index and rehydrates metadata after restart', async () => { + it('persists the locator and recency timestamp and rehydrates metadata after restart', async () => { const dataDir = tempDir('huabu-workspace-data-'); const root = tempDir('huabu-workspace-persisted-'); const filePath = registryPath(dataDir); @@ -115,6 +172,7 @@ describe('DiskWorkspaceRepository', () => { { workspaceId: workspace.workspaceId, workspacePath: path.resolve(root), + lastOpenedAt: expect.any(String), }, ], }); @@ -135,6 +193,7 @@ describe('DiskWorkspaceRepository', () => { { workspaceId: workspace.workspaceId, workspacePath: path.resolve(root), + lastOpenedAt: expect.any(String), }, ], }); @@ -165,6 +224,7 @@ describe('DiskWorkspaceRepository', () => { { workspaceId: original.workspaceId, workspacePath: path.resolve(movedPath), + lastOpenedAt: expect.any(String), }, ], }); @@ -371,7 +431,7 @@ describe('DiskWorkspaceRepository', () => { expect(repository.directoryOf(relocated.workspaceId)).toBe( path.resolve(moved), ); - await expect(repository.list()).resolves.toEqual([replacement, relocated]); + await expect(repository.list()).resolves.toEqual([relocated, replacement]); }); it('rejects a malformed durable registry instead of discarding it', async () => { diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 41364b574..20da88786 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -10,11 +10,14 @@ * user's own Spaces and `setting/` stay the visible contents. Existing Home * folders predate the manifest, so adopting one creates the file once. * - * The Server data directory holds a separate discovery index containing only - * `workspaceId -> workspacePath`. That deliberate duplication is the minimum - * needed to recognize an externally moved Workspace after restart; all other - * metadata remains authoritative in the Workspace-owned manifest and is read - * back from it on demand rather than cached here. + * The Server data directory holds a separate discovery index containing + * `workspaceId -> workspacePath` plus the last time that Workspace was opened. + * Array order has no meaning: listings sort by the explicit timestamp, and + * adopting/activating a Workspace updates its timestamp in place. That + * deliberate duplication is the minimum needed to recognize an externally + * moved Workspace after restart and preserve recency; all other metadata + * remains authoritative in the Workspace-owned manifest and is read back from + * it on demand rather than cached here. * * The index is therefore the single in-process representation of membership, * and it is re-read from disk on every access. Reads cost a few small JSON @@ -25,6 +28,7 @@ import { randomUUID } from 'node:crypto'; import { + existsSync, mkdirSync, readFileSync, realpathSync, @@ -69,6 +73,7 @@ const workspaceRegistrySchema = z .refine((value) => path.isAbsolute(value), { message: 'Workspace registry paths must be absolute', }), + lastOpenedAt: z.iso.datetime(), }) .strict(), ), @@ -176,6 +181,7 @@ function readWorkspaceRegistry(filePath: string): WorkspaceRegistryEntry[] { const entries = result.data.workspaces.map((entry) => ({ workspaceId: entry.workspaceId, workspacePath: path.resolve(entry.workspacePath), + lastOpenedAt: entry.lastOpenedAt, })); const ids = new Set(); const paths = new Set(); @@ -200,17 +206,23 @@ function isAlreadyExists(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'; } -function sameEntries( - left: readonly WorkspaceRegistryEntry[], - right: readonly WorkspaceRegistryEntry[], -): boolean { +/** Produce a strictly newer timestamp even for opens in the same millisecond. */ +function nextLastOpenedAt(entries: readonly WorkspaceRegistryEntry[]): string { + const latest = entries.reduce( + (maximum, entry) => Math.max(maximum, Date.parse(entry.lastOpenedAt)), + Number.NEGATIVE_INFINITY, + ); + return new Date(Math.max(Date.now(), latest + 1)).toISOString(); +} + +function compareMostRecentlyOpened( + left: WorkspaceRegistryEntry, + right: WorkspaceRegistryEntry, +): number { + const timestampDifference = + Date.parse(right.lastOpenedAt) - Date.parse(left.lastOpenedAt); return ( - left.length === right.length && - left.every( - (entry, index) => - entry.workspaceId === right[index]?.workspaceId && - entry.workspacePath === right[index]?.workspacePath, - ) + timestampDifference || left.workspaceId.localeCompare(right.workspaceId) ); } @@ -279,6 +291,13 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { : null; } + /** Whether this durable repository has been initialized on disk. */ + hasDurableRegistry(): boolean { + return ( + this.#registryFilePath === null || existsSync(this.#registryFilePath) + ); + } + #read(): WorkspaceRegistryEntry[] { return this.#registryFilePath ? readWorkspaceRegistry(this.#registryFilePath) @@ -342,7 +361,7 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { async list(): Promise { const handles: WorkspaceHandle[] = []; - for (const entry of this.#read()) { + for (const entry of this.#read().sort(compareMostRecentlyOpened)) { const handle = this.#hydrate(entry); if (handle) handles.push(handle); } @@ -441,16 +460,18 @@ export class DiskWorkspaceRepository implements WorkspaceRepository { const replacement: WorkspaceRegistryEntry = { workspaceId: manifest.workspaceId, workspacePath, + lastOpenedAt: nextLastOpenedAt(entries), }; - let replaced = false; - const next = surviving.map((entry) => { - if (entry.workspaceId !== manifest.workspaceId) return entry; - replaced = true; - return replacement; - }); - if (!replaced) next.push(replacement); - - if (!sameEntries(entries, next)) this.#write(next); + // Array order is deliberately stable and carries no recency semantics. + // Existing members update in place; newly discovered members append. + const existingIndex = surviving.findIndex( + (entry) => entry.workspaceId === manifest.workspaceId, + ); + const next = [...surviving]; + if (existingIndex === -1) next.push(replacement); + else next[existingIndex] = replacement; + + this.#write(next); return toHandle(manifest); } } diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 75ad86456..51ccec5b3 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -77,6 +77,7 @@ export { getStorage, getStructuredStore, getWorkspaceRepository, + hasWorkspaceRegistry, initStorage, setStorageForTesting, spaceDirectory, diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index b58d8fd38..e42d47a3d 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -171,6 +171,11 @@ export function getWorkspaceRepository(): WorkspaceRepository { return materializedWorkspaces(); } +/** Whether the Disk Workspace membership registry already exists on disk. */ +export function hasWorkspaceRegistry(): boolean { + return materializedWorkspaces().hasDurableRegistry(); +} + /** * The Workspace repository, narrowed to a backend that materializes * Workspaces as real directories. diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts index 253e5f4ee..18cca6991 100644 --- a/apps/server/src/modules/workspaces.route.test.ts +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + import fastify from 'fastify'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -29,6 +33,7 @@ const testState = vi.hoisted(() => ({ activePath: null as string | null, members: [] as TestMember[], diskIdentities: [] as TestMember[], + registryInitialized: true, })); const storageMocks = vi.hoisted(() => ({ @@ -117,13 +122,15 @@ const locatorMocks = vi.hoisted(() => ({ candidate.workspaceId !== member.workspaceId && candidate.workspacePath !== workspacePath, ); - testState.members.push(member); + testState.members.unshift(member); + testState.registryInitialized = true; return { workspaceId: member.workspaceId, name: member.name }; }), })); vi.mock('./storage/index.js', () => ({ getWorkspaceRepository: () => repository, + hasWorkspaceRegistry: () => testState.registryInitialized, resetStorageCache: storageMocks.resetStorageCache, adoptWorkspaceDirectory: locatorMocks.adoptWorkspaceDirectory, ensureWorkspaceManifestOnDisk: locatorMocks.ensureWorkspaceManifestOnDisk, @@ -176,6 +183,7 @@ async function buildApp() { beforeEach(() => { testState.managed = false; + testState.registryInitialized = true; testState.members = [ { workspaceId: FIRST_ID, @@ -226,6 +234,40 @@ describe('plural Workspace management routes', () => { } }); + it('imports the deprecated desktop store before the first list', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'huabu-route-legacy-store-')); + const legacyFile = path.join(root, 'workspace.json'); + writeFileSync( + legacyFile, + JSON.stringify({ + path: '/tmp/second', + recent: ['/tmp/second', '/tmp/first'], + }), + 'utf8', + ); + testState.members = []; + testState.registryInitialized = false; + process.env.HUABU_LEGACY_WORKSPACE_STORE = legacyFile; + const app = await buildApp(); + try { + const response = await app.inject({ method: 'GET', url: '/workspaces' }); + + expect(response.statusCode).toBe(200); + expect( + response.json().map((workspace: TestMember) => workspace.workspaceId), + ).toEqual([SECOND_ID, FIRST_ID]); + expect(activationMocks.prepareWorkspacePath.mock.calls).toEqual([ + ['/tmp/first'], + ['/tmp/second'], + ]); + expect(testState.registryInitialized).toBe(true); + } finally { + await app.close(); + delete process.env.HUABU_LEGACY_WORKSPACE_STORE; + rmSync(root, { recursive: true, force: true }); + } + }); + it('registers and prepares a Workspace without activating it', async () => { const app = await buildApp(); try { diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index d0574c2a6..02d3f0526 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -7,11 +7,13 @@ import { z } from 'zod'; import { workspaceCreateSchema, workspaceRenameSchema } from '@huabu/shared'; +import { migrateLegacyDesktopWorkspaceStore } from './legacy-desktop-workspace-store.js'; import { resetPreprocessDispatcher } from './preprocessing/index.js'; import { adoptWorkspaceDirectory, ensureWorkspaceManifestOnDisk, getWorkspaceRepository, + hasWorkspaceRegistry, resetStorageCache, workspaceAtDirectory, workspaceDirectory, @@ -174,6 +176,22 @@ interface WorkspaceParams { } const workspacesRoutes: FastifyPluginAsync = async (app) => { + let legacyMigration: Promise | null = null; + + async function ensureLegacyDesktopStoreMigrated(): Promise { + if (isManagedMode() || hasWorkspaceRegistry()) return; + const filePath = process.env.HUABU_LEGACY_WORKSPACE_STORE?.trim(); + if (!filePath) return; + legacyMigration ??= migrateLegacyDesktopWorkspaceStore(filePath, { + hasWorkspaceRegistry, + prepareWorkspacePath, + adoptWorkspaceDirectory, + }); + await legacyMigration; + } + + app.addHook('preHandler', ensureLegacyDesktopStoreMigrated); + app.get('/', async () => (await visibleWorkspaces()).map(descriptor)); app.post<{ Body: WorkspaceCreateRequest }>('/', async (request, reply) => { diff --git a/apps/web/src/api/_routes.ts b/apps/web/src/api/_routes.ts index 1ff77b5a5..627f64816 100644 --- a/apps/web/src/api/_routes.ts +++ b/apps/web/src/api/_routes.ts @@ -21,6 +21,10 @@ export const routes = { workspace: '/workspace', workspacePickFolder: '/workspace/pick-folder', workspaceValidatePath: '/workspace/validate-path', + workspaces: '/workspaces', + workspaceById: (workspaceId: string) => `/workspaces/${enc(workspaceId)}`, + workspaceActivate: (workspaceId: string) => + `/workspaces/${enc(workspaceId)}/activate`, // ── LLM ─────────────────────────────────────────────────────────── llmConfig: '/llm/config', diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts index 9f4643712..1984e2c69 100644 --- a/apps/web/src/api/workspace.ts +++ b/apps/web/src/api/workspace.ts @@ -7,6 +7,7 @@ import { getElectronBridge } from '../hooks/useElectron'; import type { PickFolderResult, + WorkspaceDescriptor, WorkspaceInfo, WorkspacePathRequest, } from '@huabu/shared'; @@ -15,6 +16,7 @@ import type { export type { PickFolderResult, WorkspaceCapabilities, + WorkspaceDescriptor, WorkspaceInfo, WorkspaceMode, } from '@huabu/shared'; @@ -51,6 +53,31 @@ export async function putWorkspacePath( }); } +/** List registered Workspaces in durable most-recently-used order. */ +export async function listWorkspaces(): Promise { + return apiFetch(routes.workspaces, { + fallbackMessage: 'Failed to list Home folders', + }); +} + +/** Activate a registered Workspace by stable identity. */ +export async function activateWorkspace( + workspaceId: string, +): Promise { + return apiFetch(routes.workspaceActivate(workspaceId), { + method: 'POST', + fallbackMessage: 'Failed to activate Home folder', + }); +} + +/** Unregister a Workspace without deleting its directory or contents. */ +export async function removeWorkspace(workspaceId: string): Promise { + await apiFetch(routes.workspaceById(workspaceId), { + method: 'DELETE', + fallbackMessage: 'Failed to remove Home folder', + }); +} + /** * (Free mode) Open a native OS folder picker dialog. * diff --git a/apps/web/src/hooks/useElectron.ts b/apps/web/src/hooks/useElectron.ts index 8b2605044..ae252553c 100644 --- a/apps/web/src/hooks/useElectron.ts +++ b/apps/web/src/hooks/useElectron.ts @@ -21,17 +21,6 @@ import { APP_NAME } from '@/config/app'; import { copyToClipboard } from '@/utils/io/clipboard'; -interface WorkspaceStoreSnapshot { - path: string | null; - recent: string[]; -} - -interface ElectronWorkspaceApi { - get: () => Promise; - set: (path: string) => Promise; - removeRecent: (path: string) => Promise; -} - interface ElectronWindowApi { isFullScreen: () => Promise; onFullScreenChange: (cb: (fullScreen: boolean) => void) => () => void; @@ -204,15 +193,6 @@ interface ElectronBridge { * number. Always present when the bridge itself is present. */ titleBarHeight: number; - /** - * Port-agnostic workspace persistence backed by a JSON file under - * `app.getPath('userData')` in the main process. Use this in - * preference to `localStorage` when present: Electron's renderer - * partitions storage by origin (scheme + host + port), and the - * shell's server port can change between launches, which would - * otherwise reset the saved workspace. - */ - workspace?: ElectronWorkspaceApi; window?: ElectronWindowApi; diagnostics?: ElectronDiagnosticsApi; dialog?: ElectronDialogApi; diff --git a/apps/web/src/pages/WorkspaceSetupPage.tsx b/apps/web/src/pages/WorkspaceSetupPage.tsx index 8162bd769..01559f23b 100644 --- a/apps/web/src/pages/WorkspaceSetupPage.tsx +++ b/apps/web/src/pages/WorkspaceSetupPage.tsx @@ -12,6 +12,8 @@ import { PathInput } from '../components/Common/PathInput'; import { APP_NAME } from '../config/app'; import { useWorkspaceStore } from '../store/workspaceStore'; +import type { WorkspaceDescriptor } from '../api/workspace'; + /** * First-launch / "switch workspace" page. * @@ -33,6 +35,9 @@ export default function WorkspaceSetupPage() { const removeRecentWorkspace = useWorkspaceStore( (s) => s.removeRecentWorkspace, ); + const activateRecentWorkspace = useWorkspaceStore( + (s) => s.activateRecentWorkspace, + ); const selectWorkspace = useWorkspaceStore((s) => s.selectWorkspace); const storeError = useWorkspaceStore((s) => s.error); @@ -65,6 +70,7 @@ export default function WorkspaceSetupPage() { isSyncing={isSyncing} storeError={storeError} recentWorkspaces={recentWorkspaces} + activateRecentWorkspace={activateRecentWorkspace} removeRecentWorkspace={removeRecentWorkspace} selectWorkspace={selectWorkspace} onActivated={() => navigate('/', { replace: true })} @@ -81,8 +87,9 @@ export default function WorkspaceSetupPage() { interface FreeSetupProps { isSyncing: boolean; storeError: string | null; - recentWorkspaces: string[]; - removeRecentWorkspace: (path: string) => void; + recentWorkspaces: WorkspaceDescriptor[]; + activateRecentWorkspace: (workspaceId: string) => Promise; + removeRecentWorkspace: (workspaceId: string) => void; selectWorkspace: (path: string) => Promise; onActivated: () => void; } @@ -91,6 +98,7 @@ function FreeSetup({ isSyncing, storeError, recentWorkspaces, + activateRecentWorkspace, removeRecentWorkspace, selectWorkspace, onActivated, @@ -118,8 +126,14 @@ function FreeSetup({ void activate(pathInput); }; - const handleSelectRecent = (path: string) => { - void activate(path); + const handleSelectRecent = async (workspaceId: string) => { + setError(null); + try { + await activateRecentWorkspace(workspaceId); + onActivated(); + } catch { + // The workspace store localizes and exposes activation failures. + } }; return ( @@ -148,29 +162,36 @@ function FreeSetup({ {t('workspace.recent')}
    - {recentWorkspaces.map((path) => ( -
  • + {recentWorkspaces.map((workspace) => ( +
  • - + {!workspace.active && ( + + )}
  • ))}
diff --git a/apps/web/src/store/workspaceStore.test.ts b/apps/web/src/store/workspaceStore.test.ts new file mode 100644 index 000000000..7b2b9deaf --- /dev/null +++ b/apps/web/src/store/workspaceStore.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useWorkspaceStore } from './workspaceStore'; + +import type { WorkspaceDescriptor, WorkspaceInfo } from '../api/workspace'; + +const FIRST_ID = '00000000-0000-4000-8000-000000000001'; +const SECOND_ID = '00000000-0000-4000-8000-000000000002'; + +const apiState = vi.hoisted(() => ({ + info: null as WorkspaceInfo | null, + workspaces: [] as WorkspaceDescriptor[], +})); + +const apiMocks = vi.hoisted(() => ({ + getWorkspaceInfo: vi.fn(async () => apiState.info as WorkspaceInfo), + listWorkspaces: vi.fn(async () => apiState.workspaces), + activateWorkspace: vi.fn(async (workspaceId: string) => { + const selected = apiState.workspaces.find( + (workspace) => workspace.workspaceId === workspaceId, + ); + if (!selected) throw new Error('Workspace not found'); + apiState.workspaces = apiState.workspaces.map((workspace) => ({ + ...workspace, + active: workspace.workspaceId === workspaceId, + })); + apiState.info = { + ...(apiState.info as WorkspaceInfo), + configured: true, + workspaceId: selected.workspaceId, + path: selected.path, + name: selected.name, + }; + return { ...selected, active: true }; + }), + putWorkspacePath: vi.fn(), + removeWorkspace: vi.fn(async (workspaceId: string) => { + apiState.workspaces = apiState.workspaces.filter( + (workspace) => workspace.workspaceId !== workspaceId, + ); + }), +})); + +vi.mock('../api/canvas', () => ({ + listCanvases: vi.fn(async () => ({ canvases: [] })), +})); + +vi.mock('../api/workspace', () => ({ + getWorkspaceInfo: apiMocks.getWorkspaceInfo, + listWorkspaces: apiMocks.listWorkspaces, + activateWorkspace: apiMocks.activateWorkspace, + putWorkspacePath: apiMocks.putWorkspacePath, + removeWorkspace: apiMocks.removeWorkspace, +})); + +function unconfiguredInfo(): WorkspaceInfo { + return { + mode: 'free', + configured: false, + workspaceId: null, + path: null, + name: null, + worldCanvasId: null, + capabilities: { canChangeWorkspace: true, nativePicker: false }, + }; +} + +function descriptors(): WorkspaceDescriptor[] { + return [ + { + workspaceId: SECOND_ID, + name: 'Second', + path: '/tmp/second', + active: false, + }, + { + workspaceId: FIRST_ID, + name: 'First', + path: '/tmp/first', + active: false, + }, + ]; +} + +describe('workspaceStore registry persistence', () => { + beforeEach(() => { + apiState.info = unconfiguredInfo(); + apiState.workspaces = descriptors(); + vi.clearAllMocks(); + useWorkspaceStore.setState({ + mode: null, + capabilities: null, + workspacePath: null, + workspaceId: null, + workspaceName: null, + worldCanvasId: null, + recentWorkspaces: [], + isReady: false, + isSyncing: false, + error: null, + canvasCount: null, + }); + }); + + it('restores the first Workspace from server-owned MRU order', async () => { + await expect(useWorkspaceStore.getState().init()).resolves.toBe(true); + + expect(apiMocks.activateWorkspace).toHaveBeenCalledWith(SECOND_ID); + expect(useWorkspaceStore.getState()).toMatchObject({ + workspaceId: SECOND_ID, + workspacePath: '/tmp/second', + workspaceName: 'Second', + isReady: true, + isSyncing: false, + }); + expect( + useWorkspaceStore + .getState() + .recentWorkspaces.map((workspace) => workspace.workspaceId), + ).toEqual([SECOND_ID, FIRST_ID]); + }); + + it('shares concurrent initialization so the MRU Workspace activates once', async () => { + const first = useWorkspaceStore.getState().init(); + const second = useWorkspaceStore.getState().init(); + + expect(second).toBe(first); + await expect(Promise.all([first, second])).resolves.toEqual([true, true]); + expect(apiMocks.activateWorkspace).toHaveBeenCalledTimes(1); + }); + + it('unregisters a recent Workspace through the plural API', async () => { + useWorkspaceStore.setState({ recentWorkspaces: descriptors() }); + + useWorkspaceStore.getState().removeRecentWorkspace(FIRST_ID); + await vi.waitFor(() => { + expect(useWorkspaceStore.getState().recentWorkspaces).toHaveLength(1); + }); + + expect(apiMocks.removeWorkspace).toHaveBeenCalledWith(FIRST_ID); + expect(useWorkspaceStore.getState().recentWorkspaces[0]?.workspaceId).toBe( + SECOND_ID, + ); + }); +}); diff --git a/apps/web/src/store/workspaceStore.ts b/apps/web/src/store/workspaceStore.ts index 385f5a479..af4130751 100644 --- a/apps/web/src/store/workspaceStore.ts +++ b/apps/web/src/store/workspaceStore.ts @@ -7,129 +7,22 @@ import { create } from 'zustand'; import { ApiError } from '../api/_client'; import { listCanvases } from '../api/canvas'; import { + activateWorkspace, getWorkspaceInfo, + listWorkspaces, putWorkspacePath, + removeWorkspace, type WorkspaceCapabilities, + type WorkspaceDescriptor, type WorkspaceInfo, type WorkspaceMode, } from '../api/workspace'; import { getElectronBridge } from '../hooks/useElectron'; import { i18n } from '../i18n'; -const FREE_PATH_KEY = 'huabu:workspace-path'; -const RECENT_PATHS_KEY = 'huabu:recent-workspaces'; -const MAX_RECENT = 5; const WORLD_ENABLED_KEY = 'huabu:world-enabled'; -/** - * Storage backend abstraction. In Electron we delegate to the main - * process (file under `userData/workspace.json`) so the saved - * workspace survives the renderer's per-origin localStorage being - * wiped whenever the shell picks a different server port. In the - * browser / Vite dev server we fall back to `localStorage`. - * - * Implementations mirror each other shape-wise so the caller doesn't - * have to branch on the environment. - */ -interface WorkspacePersistence { - load: () => Promise<{ path: string | null; recent: string[] }>; - save: (path: string) => Promise; - remove: (path: string) => Promise; -} - -function loadLocalStorageRecents(): string[] { - try { - const raw = localStorage.getItem(RECENT_PATHS_KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - if (Array.isArray(parsed)) - return parsed.filter((p): p is string => typeof p === 'string'); - } catch { - // ignore corrupt JSON - } - return []; -} - -function pushLocalStorageRecent(path: string): string[] { - const list = loadLocalStorageRecents().filter((p) => p !== path); - list.unshift(path); - const trimmed = list.slice(0, MAX_RECENT); - localStorage.setItem(RECENT_PATHS_KEY, JSON.stringify(trimmed)); - return trimmed; -} - -const localStoragePersistence: WorkspacePersistence = { - load: async () => ({ - path: localStorage.getItem(FREE_PATH_KEY), - recent: loadLocalStorageRecents(), - }), - save: async (path: string) => { - localStorage.setItem(FREE_PATH_KEY, path); - return pushLocalStorageRecent(path); - }, - remove: async (path: string) => { - const list = loadLocalStorageRecents().filter((p) => p !== path); - localStorage.setItem(RECENT_PATHS_KEY, JSON.stringify(list)); - if (localStorage.getItem(FREE_PATH_KEY) === path) { - localStorage.removeItem(FREE_PATH_KEY); - } - return list; - }, -}; - -/** - * Build the Electron-backed persistence, migrating any pre-existing - * `localStorage` values into the main-process store on first read so - * users upgrading from a previous build don't lose their selection. - */ -interface ElectronWorkspaceLike { - get: () => Promise<{ path: string | null; recent: string[] }>; - set: (path: string) => Promise<{ path: string | null; recent: string[] }>; - removeRecent: ( - path: string, - ) => Promise<{ path: string | null; recent: string[] }>; -} - -function makeElectronPersistence( - api: ElectronWorkspaceLike, -): WorkspacePersistence { - return { - load: async () => { - const snap = await api.get(); - // One-shot migration: if the main-process file is empty but the - // renderer still has a localStorage value (from an older build - // that only used localStorage), promote it so the user keeps - // their workspace across this upgrade. We only migrate the - // active path — stale recents from a different port partition - // aren't worth preserving. - if (!snap.path) { - const legacyPath = localStorage.getItem(FREE_PATH_KEY); - if (legacyPath) { - return await api.set(legacyPath); - } - } - return snap; - }, - save: async (path: string) => { - const snap = await api.set(path); - return snap.recent; - }, - remove: async (path: string) => { - const snap = await api.removeRecent(path); - return snap.recent; - }, - }; -} - -function getPersistence(): WorkspacePersistence { - const bridge = getElectronBridge(); - if (bridge?.workspace) { - return makeElectronPersistence(bridge.workspace); - } - return localStoragePersistence; -} - -const persistence = getPersistence(); +let workspaceInitInFlight: Promise | null = null; interface WorkspaceState { /** Server operating mode. `null` until the first `init()` call. */ @@ -150,8 +43,8 @@ interface WorkspaceState { /** Derived ordinary Space titles used by World Portal rendering. */ spaceTitles: Record; spaceTitlesLoaded: boolean; - /** Recently used free-mode paths (most recent first). */ - recentWorkspaces: string[]; + /** Registered free-mode Workspaces (most recently used first). */ + recentWorkspaces: WorkspaceDescriptor[]; /** Whether a workspace is ready for the app to use. */ isReady: boolean; @@ -181,8 +74,11 @@ interface WorkspaceState { /** (Free mode) Activate an absolute path. */ selectWorkspace: (path: string) => Promise; - /** (Free mode) Remove a path from the recent list. */ - removeRecentWorkspace: (path: string) => void; + /** (Free mode) Activate a registered Workspace by stable identity. */ + activateRecentWorkspace: (workspaceId: string) => Promise; + + /** (Free mode) Unregister an inactive Workspace. */ + removeRecentWorkspace: (workspaceId: string) => void; /** * Publish a freshly-counted canvas total. Pass `null` to clear the @@ -212,7 +108,7 @@ function fromInfo(info: WorkspaceInfo): Partial { /** * Notify the rest of the app that a workspace is now active. Dispatched * on every transition from "no workspace" / "different workspace" to - * "ready", including auto-activation from a saved path on boot. Stores + * "ready", including auto-activation from the Workspace registry on boot. Stores * gated by the server-side workspace guard (e.g. `acpProfilesStore`, * `useDetectedClis`) listen for this to silently re-fetch and drop the * cached "Workspace has not been configured" 503 they may have hit @@ -245,14 +141,7 @@ function workspaceActivationError(error: unknown, path: string): string { export const useWorkspaceStore = create()((set, get) => ({ mode: null, capabilities: null, - // Synchronous bootstrap value so first paint doesn't flicker the - // setup page when localStorage already holds something. The async - // `init()` call refreshes both fields from the authoritative - // persistence (Electron file or localStorage) immediately after. - workspacePath: - typeof localStorage !== 'undefined' - ? localStorage.getItem(FREE_PATH_KEY) - : null, + workspacePath: null, workspaceId: null, workspaceName: null, worldCanvasId: null, @@ -262,8 +151,7 @@ export const useWorkspaceStore = create()((set, get) => ({ : localStorage.getItem(WORLD_ENABLED_KEY) === 'true', spaceTitles: {}, spaceTitlesLoaded: false, - recentWorkspaces: - typeof localStorage !== 'undefined' ? loadLocalStorageRecents() : [], + recentWorkspaces: [], isReady: false, isSyncing: false, error: null, @@ -285,89 +173,83 @@ export const useWorkspaceStore = create()((set, get) => ({ }); }, - init: async () => { - set({ isSyncing: true, error: null }); - - // Pull the persisted snapshot up-front so we have an authoritative - // value regardless of whether we're using the Electron-backed - // store or plain localStorage. Doing this BEFORE the server call - // also lets us refresh the synchronous bootstrap value if the - // Electron file disagrees with localStorage. - const persisted = await persistence.load().catch(() => ({ - path: null as string | null, - recent: [] as string[], - })); - set({ - workspacePath: persisted.path, - recentWorkspaces: persisted.recent, - }); - - let info: WorkspaceInfo; - try { - info = await getWorkspaceInfo(); - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Server unreachable', - isSyncing: false, - }); - return false; - } - - set(fromInfo(info)); + init: () => { + workspaceInitInFlight ??= (async () => { + set({ isSyncing: true, error: null }); - // ── Managed mode: server has already activated; nothing to do. ── - if (info.mode === 'managed') { - // Free-mode leftovers are meaningless here. + let info: WorkspaceInfo; try { - await persistence.remove(persisted.path ?? ''); - } catch { - // best-effort cleanup + info = await getWorkspaceInfo(); + } catch (err) { + set({ + error: err instanceof Error ? err.message : 'Server unreachable', + isSyncing: false, + }); + return false; } - localStorage.removeItem(FREE_PATH_KEY); - set({ isSyncing: false }); - if (info.configured) emitWorkspaceChanged(); - return info.configured; - } - // ── Free mode ── - // Server already activated (e.g. another tab beat us to it). - if (info.configured && info.path) { - const recent = await persistence.save(info.path); - set({ recentWorkspaces: recent, isSyncing: false }); - emitWorkspaceChanged(); - return true; - } + set(fromInfo(info)); + + // ── Managed mode: server has already activated; nothing to do. ── + if (info.mode === 'managed') { + set({ recentWorkspaces: [], isSyncing: false }); + if (info.configured) emitWorkspaceChanged(); + return info.configured; + } - // Try to auto-activate using the remembered absolute path. - const savedPath = persisted.path; - if (savedPath) { + // ── Free mode ── + let registered: WorkspaceDescriptor[]; try { - const next = await putWorkspacePath(savedPath); - const recent = await persistence.save(savedPath); + registered = await listWorkspaces(); + set({ recentWorkspaces: registered }); + } catch (err) { set({ - ...fromInfo(next), - recentWorkspaces: recent, + error: + err instanceof Error ? err.message : 'Failed to list Workspaces', isSyncing: false, }); + return false; + } + + // Server already activated (e.g. another tab beat us to it). + if (info.configured && info.path) { + set({ isSyncing: false }); emitWorkspaceChanged(); return true; - } catch (err) { - // Stored path is invalid (e.g. cross-platform leftover). Drop it - // and fall through to setup so the user picks a fresh one. + } + + // Restore the most recently used registered Workspace. The registry is + // authoritative and activation promotes the selected entry to its front. + const saved = registered[0]; + if (saved) { try { - await persistence.remove(savedPath); - } catch { - // best-effort cleanup + await activateWorkspace(saved.workspaceId); + const [next, recent] = await Promise.all([ + getWorkspaceInfo(), + listWorkspaces(), + ]); + set({ + ...fromInfo(next), + recentWorkspaces: recent, + isSyncing: false, + }); + emitWorkspaceChanged(); + return true; + } catch (err) { + set({ + workspacePath: null, + error: workspaceActivationError(err, saved.path ?? saved.name), + }); } - set({ - workspacePath: null, - error: workspaceActivationError(err, savedPath), - }); } - } - set({ isSyncing: false }); - return false; + set({ isSyncing: false }); + return false; + })().finally(() => { + workspaceInitInFlight = null; + }); + + return workspaceInitInFlight; }, selectWorkspace: async (path: string) => { @@ -377,7 +259,7 @@ export const useWorkspaceStore = create()((set, get) => ({ set({ isSyncing: true, error: null, canvasCount: null }); try { const info = await putWorkspacePath(path); - const recent = await persistence.save(path); + const recent = await listWorkspaces(); set({ ...fromInfo(info), recentWorkspaces: recent, isSyncing: false }); emitWorkspaceChanged(); } catch (err) { @@ -387,12 +269,43 @@ export const useWorkspaceStore = create()((set, get) => ({ } }, - removeRecentWorkspace: (path: string) => { - void persistence - .remove(path) - .then((list) => set({ recentWorkspaces: list })) + activateRecentWorkspace: async (workspaceId: string) => { + if (get().mode === 'managed') { + throw new Error('Workspace is locked by the server (managed mode)'); + } + const selected = get().recentWorkspaces.find( + (workspace) => workspace.workspaceId === workspaceId, + ); + set({ isSyncing: true, error: null, canvasCount: null }); + try { + await activateWorkspace(workspaceId); + const [info, recent] = await Promise.all([ + getWorkspaceInfo(), + listWorkspaces(), + ]); + set({ ...fromInfo(info), recentWorkspaces: recent, isSyncing: false }); + emitWorkspaceChanged(); + } catch (err) { + const message = workspaceActivationError( + err, + selected?.path ?? selected?.name ?? workspaceId, + ); + set({ error: message, isSyncing: false }); + throw err; + } + }, + + removeRecentWorkspace: (workspaceId: string) => { + void removeWorkspace(workspaceId) + .then(() => + set((state) => ({ + recentWorkspaces: state.recentWorkspaces.filter( + (workspace) => workspace.workspaceId !== workspaceId, + ), + })), + ) .catch(() => { - // Surface nothing — the recents list is best-effort UX. + // Surface nothing — removing an inactive registration is best-effort. }); }, diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 104ea7335..2c16acb94 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -13,7 +13,7 @@ Runtime Home-folder activation reserves the namespace switch before preparing an ``` / storage/disk/ - workspaces.json # durable workspaceId -> absolute path index + workspaces.json # MRU-ordered workspaceId -> absolute path index / .workspace.json # stable Workspace identity + display name @@ -45,7 +45,8 @@ Runtime Home-folder activation reserves the namespace switch before preparing an Key points: -- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries. It is the single in-process representation of membership: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries in most-recently-used order. Successful adoption or activation promotes an entry to the front. It is the single in-process representation of membership and the welcome screen's source: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- Electron's former `/workspace.json` path/recents store is deprecated. `start:desktop` passes its location to the Server as a read-only upgrade source; the first plural Workspace request imports its active path and recents only when `workspaces.json` does not yet exist. An existing registry always wins and the old file is never written again. - Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. If it is active, the process-local active path moves with that registration before the API publishes the new location. Two physically distinct live paths carrying the same id are rejected as a copied-identity conflict; symlink aliases of one directory are the same materialization, not copies. A path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. - A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest. - Workspace identity is a precondition of storage rather than a product of it, so the composition root resolves the async Workspace repository (`getWorkspaceRepository()`) on its own axis, separate from `StructuredStore`. The configured backend connections are process-wide: selecting another Workspace changes the active namespace inside those existing connections and does not drop or reconnect them. A SQL adapter therefore holds all Workspace membership and data behind one live connection/pool, with Workspace ids scoping repository and handle operations. Managed-mode Disk adoption still happens while `app.ts` is evaluating; a connection-backed adapter wires its repository during the awaited storage startup. diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md index c1193a277..2bc6c8a7a 100644 --- a/docs/architecture/web-architecture.md +++ b/docs/architecture/web-architecture.md @@ -247,7 +247,7 @@ Network deployment follows the single-owner boundary in [`deployment-security.md The packaged desktop app exposes three support actions without granting the renderer general filesystem or Electron access: reveal the canonical Server log, open Chromium Developer Tools, and copy non-sensitive system information (Huabu version, OS release, CPU architecture, and Electron version). The sandboxed preload bridge exposes only these fixed operations under `electronBridge.diagnostics`; filesystem paths and shell calls remain in the main process. Packaged builds resolve the log below Electron's `userData/data`; `dev:desktop` passes the source Server's `apps/server/data` location to both processes through `HUABU_DATA_DIR`, so the same action always reveals the log written by the active Server. -The Electron-owned `userData` tree (the port-agnostic `workspace.json`, Chromium storage, and Electron's own logs / crash dumps) is partitioned by `app.setName`, keyed off whether `EXTERNAL_SERVER_URL` is set (the signal that `scripts/dev-desktop.mjs`'s HMR orchestrator is driving this run): `dev:desktop` anchors on `Huabu Dev` to isolate its actively-changing tsx-watch/Vite code from real user state, while both a packaged install and `pnpm start:desktop` (an unpackaged run of the exact production bundle, used as a pre-release smoke test) anchor on `Huabu` and intentionally share the same on-disk state. The Server data dir follows separately — `start:desktop` derives it from `/data` (inheriting `Huabu`), while `dev:desktop` overrides it to the in-repo `apps/server/data`. Credentials are not affected by this name split: only `start:desktop` / packaged installs use the `safeStorage`-backed `/data/secure-secrets.json` (and both resolve to `Huabu`, so they share it), whereas `dev:desktop` skips `safeStorage` entirely and persists secrets to `apps/server/data/encrypted-secrets.json` via `HUABU_SECRET_KEY`. +The Electron-owned `userData` tree (Chromium storage and Electron's own logs / crash dumps) is partitioned by `app.setName`, keyed off whether `EXTERNAL_SERVER_URL` is set (the signal that `scripts/dev-desktop.mjs`'s HMR orchestrator is driving this run): `dev:desktop` anchors on `Huabu Dev` to isolate its actively-changing tsx-watch/Vite code from real user state, while both a packaged install and `pnpm start:desktop` (an unpackaged run of the exact production bundle, used as a pre-release smoke test) anchor on `Huabu` and intentionally share the same on-disk state. The Server data dir follows separately — `start:desktop` derives it from `/data` (inheriting `Huabu`), while `dev:desktop` overrides it to the in-repo `apps/server/data`. Workspace membership, restore state, and MRU ordering live in the Server-owned `/storage/disk/workspaces.json`. The former port-agnostic `/workspace.json` is deprecated and read only as a one-time upgrade source when the new registry does not yet exist. Credentials are not affected by this name split: only `start:desktop` / packaged installs use the `safeStorage`-backed `/data/secure-secrets.json` (and both resolve to `Huabu`, so they share it), whereas `dev:desktop` skips `safeStorage` entirely and persists secrets to `apps/server/data/encrypted-secrets.json` via `HUABU_SECRET_KEY`. The native macOS Help menu and the Windows/Linux in-app application menu reuse the fixed operations exported by [`useElectron.ts`](../../apps/web/src/hooks/useElectron.ts) and add localized feedback at the UI boundary. On Windows and Linux, Troubleshooting is a side-opening submenu composed from the shared `DropdownMenu` primitives rather than a flat group of support actions. The browser build omits the actions because the diagnostics bridge is absent. From 93371384ce02372fa151100178c01cbaed3c67f0 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 26 Aug 2026 15:36:17 +0800 Subject: [PATCH 9/9] fix(workspace): import the deprecated desktop store without preparing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01HCuWpzgCDK7bTofSJkqPWT --- .../modules/desktop-workspace-upgrade.test.ts | 393 +++++++++++++++++ .../legacy-desktop-workspace-store.test.ts | 414 ++++++++++++++---- .../modules/legacy-desktop-workspace-store.ts | 81 +++- .../backends/disk/workspace-repository.ts | 21 + apps/server/src/modules/storage/index.ts | 5 +- .../src/modules/workspaces.route.test.ts | 108 ++++- apps/server/src/modules/workspaces.route.ts | 26 +- apps/web/src/store/workspaceStore.test.ts | 23 + apps/web/src/store/workspaceStore.ts | 25 +- docs/architecture/canvas-storage.md | 2 +- 10 files changed, 977 insertions(+), 121 deletions(-) create mode 100644 apps/server/src/modules/desktop-workspace-upgrade.test.ts diff --git a/apps/server/src/modules/desktop-workspace-upgrade.test.ts b/apps/server/src/modules/desktop-workspace-upgrade.test.ts new file mode 100644 index 000000000..24765eda8 --- /dev/null +++ b/apps/server/src/modules/desktop-workspace-upgrade.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * End-to-end upgrade of a desktop install that predates the plural Workspace + * registry, over the production routes and against real storage. + * + * Nothing here is mocked. The Disk Workspace repository, the durable registry + * under `HUABU_DATA_DIR`, the Workspace manifests, and both Workspace route + * plugins are the real ones, mounted at the same prefixes `app.ts` uses. The + * only thing this suite substitutes for is the process boundary around + * activation: `defaultWorkerPath()` resolves to the TypeScript worker when the + * Server is not bundled, and Vitest does not propagate a TS loader to a + * `fork()`ed child. Activation therefore runs through `setWorkspacePath()`, + * which `workspace-prepare.ts` documents as the in-process entry point for + * startup and tests and which performs exactly the same on-disk work. The + * isolation itself is covered by `workspace-activation.test.ts`. + * + * The story under test is the one an upgrading user actually walks through: + * an Electron `/workspace.json` naming Home folders they used + * before, some of which no longer exist, then a first launch that must + * recover their history without touching folders they did not open. + */ + +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { workspaceRegistryPath } from './storage/backends/disk/workspace-repository.js'; +import { resetStorageCache } from './storage/index.js'; +import { setWorkspacePath } from './workspace.js'; +import workspaceRoutes from './workspace.route.js'; +import workspacesRoutes from './workspaces.route.js'; + +import type { WorkspaceDescriptor, WorkspaceInfo } from '@huabu/shared'; +import type { FastifyInstance } from 'fastify'; + +const WORLD_DIR = '.world'; +const MANIFEST = '.workspace.json'; + +describe('desktop upgrade to the plural Workspace registry', () => { + const roots: string[] = []; + let userData: string; + let legacyFile: string; + + function tempDir(prefix: string): string { + const root = mkdtempSync(path.join(tmpdir(), prefix)); + roots.push(root); + return root; + } + + /** Mount the Workspace plugins at the prefixes `app.ts` uses. */ + async function buildApp(): Promise { + const app = fastify(); + await app.register(workspaceRoutes, { prefix: '/api/workspace' }); + await app.register(workspacesRoutes, { prefix: '/api/workspaces' }); + await app.ready(); + return app; + } + + /** A Home folder as an older Huabu left it: content, but no manifest. */ + function seedLegacyHome(prefix: string, spaceTitle: string): string { + const home = tempDir(prefix); + const space = path.join(home, spaceTitle); + mkdirSync(space, { recursive: true }); + // `canvas.json` is the pre-rename name `migrateCanvasToSpace` converts. + writeFileSync( + path.join(space, 'canvas.json'), + JSON.stringify({ canvasId: `canvas-${spaceTitle}`, title: spaceTitle }), + 'utf8', + ); + return home; + } + + function registryFile(): string { + return workspaceRegistryPath(process.env.HUABU_DATA_DIR as string); + } + + function readRegistry(): { + schemaVersion: number; + workspaces: { workspacePath: string; lastOpenedAt: string }[]; + } { + return JSON.parse(readFileSync(registryFile(), 'utf8')) as ReturnType< + typeof readRegistry + >; + } + + async function listWorkspaces( + app: FastifyInstance, + ): Promise { + const response = await app.inject({ + method: 'GET', + url: '/api/workspaces', + }); + expect(response.statusCode).toBe(200); + return response.json() as WorkspaceDescriptor[]; + } + + beforeEach(() => { + userData = tempDir('huabu-upgrade-userdata-'); + legacyFile = path.join(userData, 'workspace.json'); + process.env.HUABU_LEGACY_WORKSPACE_STORE = legacyFile; + // A fresh install of the new build: the durable registry does not exist + // yet. The repository re-reads the file on every access, so removing it is + // a complete reset even though the instance is process-wide. + rmSync(registryFile(), { force: true }); + resetStorageCache(); + }); + + afterEach(() => { + delete process.env.HUABU_LEGACY_WORKSPACE_STORE; + rmSync(registryFile(), { force: true }); + resetStorageCache(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('recovers the remembered Home folders on the first launch', async () => { + const older = seedLegacyHome('huabu-upgrade-older-', 'Older Space'); + const active = seedLegacyHome('huabu-upgrade-active-', 'Active Space'); + const deleted = path.join(userData, 'home-the-user-deleted'); + writeFileSync( + legacyFile, + JSON.stringify({ + path: active, + recent: [active, older, deleted], + }), + 'utf8', + ); + + const app = await buildApp(); + try { + const listed = await listWorkspaces(app); + + // Most recently used first, with the entry whose folder is gone dropped. + expect(listed.map((workspace) => workspace.path)).toEqual([ + path.resolve(active), + path.resolve(older), + ]); + expect(listed.every((workspace) => workspace.active === false)).toBe( + true, + ); + + // The registry now exists and carries recency as data, not array order. + const registry = readRegistry(); + expect(registry.schemaVersion).toBe(1); + expect(registry.workspaces.map((entry) => entry.workspacePath)).toEqual([ + path.resolve(older), + path.resolve(active), + ]); + const stamps = registry.workspaces.map((entry) => + Date.parse(entry.lastOpenedAt), + ); + expect(stamps[1]).toBeGreaterThan(stamps[0] ?? Number.NaN); + + // Recovering history is not opening a folder: the deleted Home folder + // stays deleted, and neither survivor is prepared or migrated. + expect(existsSync(deleted)).toBe(false); + // Adoption adds the identity manifest and nothing else. Preparation + // would have added the world canvas and rewritten the legacy Space. + expect(readdirSync(active).sort()).toEqual( + [MANIFEST, 'Active Space'].sort(), + ); + expect(readdirSync(older).sort()).toEqual( + [MANIFEST, 'Older Space'].sort(), + ); + expect(existsSync(path.join(active, 'Active Space', 'canvas.json'))).toBe( + true, + ); + } finally { + await app.close(); + } + }); + + it('prepares only the Workspace the user actually opens', async () => { + const older = seedLegacyHome('huabu-upgrade-older-', 'Older Space'); + const active = seedLegacyHome('huabu-upgrade-active-', 'Active Space'); + writeFileSync( + legacyFile, + JSON.stringify({ path: active, recent: [active, older] }), + 'utf8', + ); + + const app = await buildApp(); + try { + const listed = await listWorkspaces(app); + const target = listed[0]; + expect(target?.path).toBe(path.resolve(active)); + + // What the web client does next with the restored MRU entry. + setWorkspacePath(target?.path as string); + + // The opened Workspace is migrated: legacy `canvas.json` became + // `space.json` and the world canvas exists. + expect(existsSync(path.join(active, WORLD_DIR))).toBe(true); + expect(existsSync(path.join(active, 'Active Space', 'space.json'))).toBe( + true, + ); + // The one the user did not open is still exactly as it was found. + expect(existsSync(path.join(older, WORLD_DIR))).toBe(false); + expect(existsSync(path.join(older, 'Older Space', 'canvas.json'))).toBe( + true, + ); + + const afterActivation = await listWorkspaces(app); + expect(afterActivation[0]?.path).toBe(path.resolve(active)); + expect(afterActivation[0]?.active).toBe(true); + + const info = await app.inject({ method: 'GET', url: '/api/workspace' }); + expect(info.statusCode).toBe(200); + expect(info.json() as WorkspaceInfo).toMatchObject({ + mode: 'free', + configured: true, + path: path.resolve(active), + }); + } finally { + await app.close(); + } + }); + + it('never consults the deprecated file again once the registry exists', async () => { + const first = seedLegacyHome('huabu-upgrade-first-', 'First Space'); + writeFileSync( + legacyFile, + JSON.stringify({ path: first, recent: [first] }), + 'utf8', + ); + + const firstLaunch = await buildApp(); + try { + expect(await listWorkspaces(firstLaunch)).toHaveLength(1); + } finally { + await firstLaunch.close(); + } + + // The user removes the Home folder through the app, then relaunches. A + // second import would silently resurrect the registration they just + // dropped, so the registry's existence has to win over the legacy file. + const relaunch = await buildApp(); + try { + const listed = await listWorkspaces(relaunch); + const removal = await relaunch.inject({ + method: 'DELETE', + url: `/api/workspaces/${listed[0]?.workspaceId ?? ''}`, + }); + expect(removal.statusCode).toBe(204); + expect(await listWorkspaces(relaunch)).toEqual([]); + } finally { + await relaunch.close(); + } + + const afterRemoval = await buildApp(); + try { + expect(await listWorkspaces(afterRemoval)).toEqual([]); + // Unregistering never deletes the Workspace itself. + expect(existsSync(path.join(first, MANIFEST))).toBe(true); + } finally { + await afterRemoval.close(); + } + }); + + it('restores the folder in use, not a copy of it left in the recents', async () => { + const active = seedLegacyHome('huabu-upgrade-real-', 'Space'); + // Give it an identity, then duplicate the folder — a user keeping a + // backup copy of their Home folder, with both in the recents list. + writeFileSync( + path.join(active, MANIFEST), + JSON.stringify({ + schemaVersion: 1, + workspaceId: '11111111-1111-4111-8111-111111111111', + name: 'Real', + }), + 'utf8', + ); + const backup = path.join(tempDir('huabu-upgrade-backup-'), 'backup'); + cpSync(active, backup, { recursive: true }); + writeFileSync( + legacyFile, + JSON.stringify({ path: active, recent: [active, backup] }), + 'utf8', + ); + + const app = await buildApp(); + try { + // Only one of the two can hold the identity. Restoring the user into a + // stale copy of their own Workspace would be the worst outcome here, so + // the recent path has to win over the older duplicate. + const listed = await listWorkspaces(app); + expect(listed.map((workspace) => workspace.path)).toEqual([ + path.resolve(active), + ]); + } finally { + await app.close(); + } + }); + + it('treats a symlink alias and its target as one Workspace', async () => { + const real = seedLegacyHome('huabu-upgrade-target-', 'Space'); + const link = path.join(tempDir('huabu-upgrade-alias-'), 'link'); + symlinkSync(real, link, 'dir'); + writeFileSync( + legacyFile, + JSON.stringify({ path: link, recent: [link, real] }), + 'utf8', + ); + + const app = await buildApp(); + try { + const listed = await listWorkspaces(app); + expect(listed.map((workspace) => workspace.path)).toEqual([ + path.resolve(link), + ]); + } finally { + await app.close(); + } + }); + + it('imports once when the first requests arrive together', async () => { + const one = seedLegacyHome('huabu-upgrade-one-', 'One'); + const two = seedLegacyHome('huabu-upgrade-two-', 'Two'); + writeFileSync( + legacyFile, + JSON.stringify({ path: one, recent: [one, two] }), + 'utf8', + ); + + const app = await buildApp(); + try { + const responses = await Promise.all( + Array.from({ length: 8 }, () => + app.inject({ method: 'GET', url: '/api/workspaces' }), + ), + ); + + for (const response of responses) { + expect(response.statusCode).toBe(200); + expect(response.json() as WorkspaceDescriptor[]).toHaveLength(2); + } + expect(readRegistry().workspaces).toHaveLength(2); + } finally { + await app.close(); + } + }); + + it('serves the collection normally when there is nothing to upgrade', async () => { + const app = await buildApp(); + try { + // No `/workspace.json` at all — a fresh install, not an + // upgrade. The import must be a silent no-op, not an error. + expect(existsSync(legacyFile)).toBe(false); + + expect(await listWorkspaces(app)).toEqual([]); + expect(existsSync(registryFile())).toBe(false); + } finally { + await app.close(); + } + }); + + it('refuses to unregister the Workspace that is currently active', async () => { + const home = seedLegacyHome('huabu-upgrade-active-only-', 'Only Space'); + writeFileSync(legacyFile, JSON.stringify({ path: home }), 'utf8'); + + const app = await buildApp(); + try { + const listed = await listWorkspaces(app); + setWorkspacePath(listed[0]?.path as string); + + const removal = await app.inject({ + method: 'DELETE', + url: `/api/workspaces/${listed[0]?.workspaceId ?? ''}`, + }); + + expect(removal.statusCode).toBe(409); + expect(await listWorkspaces(app)).toHaveLength(1); + } finally { + await app.close(); + } + }); +}); diff --git a/apps/server/src/modules/legacy-desktop-workspace-store.test.ts b/apps/server/src/modules/legacy-desktop-workspace-store.test.ts index 2deb707eb..9a08ae8ef 100644 --- a/apps/server/src/modules/legacy-desktop-workspace-store.test.ts +++ b/apps/server/src/modules/legacy-desktop-workspace-store.test.ts @@ -1,7 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -11,6 +20,7 @@ import { migrateLegacyDesktopWorkspaceStore } from './legacy-desktop-workspace-s import { DiskWorkspaceRepository, WORKSPACE_MANIFEST_FILENAME, + workspaceIdentityOnDisk, } from './storage/backends/disk/workspace-repository.js'; describe('deprecated desktop Workspace store migration', () => { @@ -22,105 +32,345 @@ describe('deprecated desktop Workspace store migration', () => { return root; } + /** A data dir plus the registry path the Disk backend would derive in it. */ + function harness(prefix: string): { + dataDir: string; + legacyFile: string; + repository: DiskWorkspaceRepository; + } { + const dataDir = tempDir(prefix); + return { + dataDir, + legacyFile: path.join(dataDir, 'workspace.json'), + repository: new DiskWorkspaceRepository( + path.join(dataDir, 'storage', 'disk', 'workspaces.json'), + ), + }; + } + + function writeLegacyStore(legacyFile: string, contents: unknown): void { + writeFileSync(legacyFile, JSON.stringify(contents), 'utf8'); + } + + function importInto( + legacyFile: string, + repository: DiskWorkspaceRepository, + ): void { + migrateLegacyDesktopWorkspaceStore(legacyFile, { + hasWorkspaceRegistry: () => repository.hasDurableRegistry(), + adoptWorkspaceDirectory: (workspacePath) => + repository.adopt(workspacePath), + workspaceIdentityOnDisk, + }); + } + + async function registeredPaths( + repository: DiskWorkspaceRepository, + ): Promise<(string | null)[]> { + const listed = await repository.list(); + return listed.map((workspace) => + repository.directoryOf(workspace.workspaceId), + ); + } + afterEach(() => { for (const root of roots.splice(0)) { rmSync(root, { recursive: true, force: true }); } }); - it('imports the legacy active path and recents in MRU order', async () => { - const dataDir = tempDir('huabu-legacy-store-data-'); - const first = tempDir('huabu-legacy-store-first-'); - const second = tempDir('huabu-legacy-store-second-'); - const legacyFile = path.join(dataDir, 'workspace.json'); - const registryFile = path.join( - dataDir, - 'data', - 'storage', - 'disk', - 'workspaces.json', - ); - writeFileSync( - legacyFile, - JSON.stringify({ path: second, recent: [second, first] }), - 'utf8', - ); - const repository = new DiskWorkspaceRepository(registryFile); - const prepareWorkspacePath = vi.fn(async (workspacePath: string) => - path.resolve(workspacePath), - ); + describe('what it imports', () => { + it('imports the legacy active path and recents in MRU order', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-data-'); + const first = tempDir('huabu-legacy-store-first-'); + const second = tempDir('huabu-legacy-store-second-'); + writeLegacyStore(legacyFile, { path: second, recent: [second, first] }); - await migrateLegacyDesktopWorkspaceStore(legacyFile, { - hasWorkspaceRegistry: () => repository.hasDurableRegistry(), - prepareWorkspacePath, - adoptWorkspaceDirectory: (workspacePath) => - repository.adopt(workspacePath), + importInto(legacyFile, repository); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(second), + path.resolve(first), + ]); + expect( + readFileSync(path.join(first, WORKSPACE_MANIFEST_FILENAME), 'utf8'), + ).toContain('workspaceId'); }); - expect( - prepareWorkspacePath.mock.calls.map(([workspacePath]) => workspacePath), - ).toEqual([first, second]); - const listed = await repository.list(); - expect( - listed.map((workspace) => repository.directoryOf(workspace.workspaceId)), - ).toEqual([path.resolve(second), path.resolve(first)]); - expect( - readFileSync(path.join(first, WORKSPACE_MANIFEST_FILENAME), 'utf8'), - ).toContain('workspaceId'); + it('orders by a strictly increasing timestamp, not by array position', async () => { + const { dataDir, legacyFile, repository } = harness( + 'huabu-legacy-store-stamps-', + ); + const older = tempDir('huabu-legacy-store-older-'); + const newer = tempDir('huabu-legacy-store-newer-'); + writeLegacyStore(legacyFile, { path: newer, recent: [newer, older] }); + + importInto(legacyFile, repository); + + const registry = JSON.parse( + readFileSync( + path.join(dataDir, 'storage', 'disk', 'workspaces.json'), + 'utf8', + ), + ) as { workspaces: { workspacePath: string; lastOpenedAt: string }[] }; + // Written oldest-first, so array order is the reverse of the MRU listing. + // Only the timestamps carry recency. + expect(registry.workspaces.map((entry) => entry.workspacePath)).toEqual([ + path.resolve(older), + path.resolve(newer), + ]); + const stamps = registry.workspaces.map((entry) => + Date.parse(entry.lastOpenedAt), + ); + expect(stamps[1]).toBeGreaterThan(stamps[0] ?? Number.NaN); + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(newer), + path.resolve(older), + ]); + }); + + it('imports an active path that the recents list never mentioned', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-active-'); + const active = tempDir('huabu-legacy-store-active-only-'); + writeLegacyStore(legacyFile, { path: active }); + + importInto(legacyFile, repository); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(active), + ]); + }); + + it('imports recents when the legacy store has no active path', async () => { + const { legacyFile, repository } = harness( + 'huabu-legacy-store-norecent-', + ); + const only = tempDir('huabu-legacy-store-recent-only-'); + writeLegacyStore(legacyFile, { path: null, recent: [only] }); + + importInto(legacyFile, repository); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(only), + ]); + }); + + it('caps the import at six entries', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-cap-'); + const recent = Array.from({ length: 8 }, (_unused, index) => + tempDir(`huabu-legacy-store-cap-${index}-`), + ); + writeLegacyStore(legacyFile, { path: recent[0], recent }); + + importInto(legacyFile, repository); + + // The six most recent survive; the tail of the legacy list is dropped. + await expect(registeredPaths(repository)).resolves.toEqual( + recent.slice(0, 6).map((entry) => path.resolve(entry)), + ); + }); + + it('collapses path spellings that name the same directory', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-dupes-'); + const home = tempDir('huabu-legacy-store-duped-'); + writeLegacyStore(legacyFile, { + path: `${home}${path.sep}`, + recent: [home, path.join(home, '.', ''), `${home}${path.sep}`], + }); + + importInto(legacyFile, repository); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(home), + ]); + }); + + it('skips entries that are not absolute paths', async () => { + const { legacyFile, repository } = harness( + 'huabu-legacy-store-relative-', + ); + writeLegacyStore(legacyFile, { + path: 'relative/home', + recent: ['./also-relative', 42, null], + }); + + importInto(legacyFile, repository); + + expect(repository.hasDurableRegistry()).toBe(false); + await expect(repository.list()).resolves.toEqual([]); + }); + + it('never resolves a relative entry against the Server working directory', () => { + const { legacyFile, repository } = harness('huabu-legacy-store-cwd-'); + // A relative entry that *would* name a real directory if anything here + // resolved it against `process.cwd()`. The legacy store was written by + // the Electron main process, so its relative entries mean nothing to + // this Server and must never be adopted into its own working directory. + const relative = '.huabu-legacy-store-cwd-probe'; + const probe = path.join(process.cwd(), relative); + mkdirSync(probe, { recursive: true }); + roots.push(probe); + writeLegacyStore(legacyFile, { + path: relative, + recent: [`.${path.sep}${relative}`], + }); + + importInto(legacyFile, repository); + + expect(existsSync(path.join(probe, WORKSPACE_MANIFEST_FILENAME))).toBe( + false, + ); + expect(repository.hasDurableRegistry()).toBe(false); + }); }); - it('ignores the deprecated file once workspaces.json exists', async () => { - const dataDir = tempDir('huabu-legacy-store-existing-data-'); - const existing = tempDir('huabu-legacy-store-existing-'); - const legacy = tempDir('huabu-legacy-store-ignored-'); - const legacyFile = path.join(dataDir, 'workspace.json'); - const registryFile = path.join(dataDir, 'workspaces.json'); - writeFileSync( - legacyFile, - JSON.stringify({ path: legacy, recent: [legacy] }), - 'utf8', - ); - const repository = new DiskWorkspaceRepository(registryFile); - const registered = repository.adopt(existing); - const prepareWorkspacePath = vi.fn( - async (workspacePath: string) => workspacePath, - ); + describe('what it refuses to touch', () => { + it('registers without preparing, leaving only the identity manifest', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-inert-'); + const home = tempDir('huabu-legacy-store-untouched-'); + writeLegacyStore(legacyFile, { path: home, recent: [home] }); + + importInto(legacyFile, repository); + + // Preparation would have added the world canvas directory and run the + // whole on-disk migration chain against a Workspace nobody opened. + expect(readdirSync(home)).toEqual([WORKSPACE_MANIFEST_FILENAME]); + }); + + it('drops remembered paths that are no longer directories on disk', async () => { + const { dataDir, legacyFile, repository } = harness( + 'huabu-legacy-store-empty-data-', + ); + const missing = path.join(dataDir, 'deleted-home-folder'); + writeLegacyStore(legacyFile, { path: missing, recent: [missing] }); + + importInto(legacyFile, repository); + + // A remembered path is not a request to open a folder: adopting it would + // have recreated the directory the user deleted. + expect(existsSync(missing)).toBe(false); + expect(repository.hasDurableRegistry()).toBe(false); + await expect(repository.list()).resolves.toEqual([]); + }); + + it('drops a remembered path that now names a file', async () => { + const { dataDir, legacyFile, repository } = harness( + 'huabu-legacy-store-file-', + ); + const notADirectory = path.join(dataDir, 'home.txt'); + writeFileSync(notADirectory, 'not a Workspace', 'utf8'); + writeLegacyStore(legacyFile, { path: notADirectory }); + + importInto(legacyFile, repository); + + expect(repository.hasDurableRegistry()).toBe(false); + expect(readFileSync(notADirectory, 'utf8')).toBe('not a Workspace'); + }); + + it('follows a symlink that still points at a real directory', async () => { + const { dataDir, legacyFile, repository } = harness( + 'huabu-legacy-store-symlink-', + ); + const target = tempDir('huabu-legacy-store-symlink-target-'); + const link = path.join(dataDir, 'home-link'); + symlinkSync(target, link, 'dir'); + writeLegacyStore(legacyFile, { path: link }); + + importInto(legacyFile, repository); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(link), + ]); + }); + + it('ignores the deprecated file once workspaces.json exists', async () => { + const { legacyFile, repository } = harness( + 'huabu-legacy-store-existing-data-', + ); + const existing = tempDir('huabu-legacy-store-existing-'); + const legacy = tempDir('huabu-legacy-store-ignored-'); + writeLegacyStore(legacyFile, { path: legacy, recent: [legacy] }); + const registered = repository.adopt(existing); + const adoptWorkspaceDirectory = vi.fn((workspacePath: string) => { + repository.adopt(workspacePath); + }); - await expect( migrateLegacyDesktopWorkspaceStore(legacyFile, { hasWorkspaceRegistry: () => repository.hasDurableRegistry(), - prepareWorkspacePath, - adoptWorkspaceDirectory: (workspacePath) => - repository.adopt(workspacePath), - }), - ).resolves.toBeUndefined(); - - expect(prepareWorkspacePath).not.toHaveBeenCalled(); - await expect(repository.list()).resolves.toEqual([registered]); + adoptWorkspaceDirectory, + workspaceIdentityOnDisk, + }); + + expect(adoptWorkspaceDirectory).not.toHaveBeenCalled(); + expect(existsSync(path.join(legacy, WORKSPACE_MANIFEST_FILENAME))).toBe( + false, + ); + await expect(repository.list()).resolves.toEqual([registered]); + }); }); - it('leaves the registry absent when no legacy entry can be migrated', async () => { - const dataDir = tempDir('huabu-legacy-store-empty-data-'); - const legacyFile = path.join(dataDir, 'workspace.json'); - writeFileSync( - legacyFile, - JSON.stringify({ path: '/unavailable', recent: ['/unavailable'] }), - 'utf8', - ); - const repository = new DiskWorkspaceRepository( - path.join(dataDir, 'storage', 'disk', 'workspaces.json'), - ); + describe('when the legacy store is unusable', () => { + it('stays silent when the deprecated file was never written', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-absent-'); - await migrateLegacyDesktopWorkspaceStore(legacyFile, { - hasWorkspaceRegistry: () => repository.hasDurableRegistry(), - prepareWorkspacePath: async () => { - throw new Error('unavailable'); - }, - adoptWorkspaceDirectory: (workspacePath) => - repository.adopt(workspacePath), + expect(() => importInto(legacyFile, repository)).not.toThrow(); + + expect(repository.hasDurableRegistry()).toBe(false); + await expect(repository.list()).resolves.toEqual([]); }); - expect(repository.hasDurableRegistry()).toBe(false); - await expect(repository.list()).resolves.toEqual([]); + it('survives a truncated or malformed deprecated file', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-broken-'); + writeFileSync(legacyFile, '{"path": "/tmp/hom', 'utf8'); + + expect(() => importInto(legacyFile, repository)).not.toThrow(); + + expect(repository.hasDurableRegistry()).toBe(false); + }); + + it('survives a deprecated file that is not an object', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-scalar-'); + writeLegacyStore(legacyFile, ['/tmp/first']); + + expect(() => importInto(legacyFile, repository)).not.toThrow(); + + expect(repository.hasDurableRegistry()).toBe(false); + }); + + it('registers the surviving entries when one of them cannot be adopted', async () => { + const { legacyFile, repository } = harness( + 'huabu-legacy-store-partial-data-', + ); + const healthy = tempDir('huabu-legacy-store-healthy-'); + const broken = tempDir('huabu-legacy-store-broken-entry-'); + writeLegacyStore(legacyFile, { + path: healthy, + recent: [healthy, broken], + }); + + migrateLegacyDesktopWorkspaceStore(legacyFile, { + hasWorkspaceRegistry: () => repository.hasDurableRegistry(), + adoptWorkspaceDirectory: (workspacePath) => { + if (workspacePath === path.resolve(broken)) { + throw new Error('copied Workspace identity'); + } + repository.adopt(workspacePath); + }, + workspaceIdentityOnDisk, + }); + + await expect(registeredPaths(repository)).resolves.toEqual([ + path.resolve(healthy), + ]); + }); + + it('imports nothing when the deprecated store had nothing to remember', async () => { + const { legacyFile, repository } = harness('huabu-legacy-store-blank-'); + writeLegacyStore(legacyFile, { path: null, recent: [] }); + + importInto(legacyFile, repository); + + expect(repository.hasDurableRegistry()).toBe(false); + }); }); }); diff --git a/apps/server/src/modules/legacy-desktop-workspace-store.ts b/apps/server/src/modules/legacy-desktop-workspace-store.ts index a67c41d24..3da3fbd54 100644 --- a/apps/server/src/modules/legacy-desktop-workspace-store.ts +++ b/apps/server/src/modules/legacy-desktop-workspace-store.ts @@ -9,9 +9,20 @@ * Server where the old file lived so an upgrade can import it, but only while * `workspaces.json` does not exist. Once any entry creates the new registry, * the legacy file is never consulted again. + * + * Importing only *registers* membership: each remembered path is checked for a + * directory still on disk and adopted, which writes at most the Workspace + * manifest that identity requires. It deliberately does not prepare anything. + * A remembered path is a claim about the past, not a request to open a folder, + * so preparing here would recreate directories the user has since deleted and + * run the whole on-disk migration chain against Workspaces nobody asked for. + * Preparation stays with the activation the user actually performs, which is + * also what keeps this import off the fork-and-await path: the collection is + * never held behind a preparation timeout per remembered folder. */ -import { readFileSync } from 'node:fs'; +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; import { getLogger } from '../utils/logger.js'; @@ -20,8 +31,19 @@ const log = getLogger('legacy-desktop-workspace-store'); export interface LegacyWorkspaceMigrationDependencies { hasWorkspaceRegistry: () => boolean; - prepareWorkspacePath: (workspacePath: string) => Promise; adoptWorkspaceDirectory: (workspacePath: string) => void; + /** + * The identity a directory already claims, or null when it claims none. + * + * Two remembered paths can name one copied Workspace, and only one of them + * can be registered — adoption refuses the second. Asking first is what + * lets the recent path win instead of whichever the loop reaches first. + * Most legacy folders predate the manifest and claim nothing, so on a + * typical upgrade this answers null for every entry. + */ + workspaceIdentityOnDisk: ( + workspacePath: string, + ) => { workspaceId: string } | null; } function legacyWorkspacePaths(raw: unknown): string[] { @@ -33,17 +55,30 @@ function legacyWorkspacePaths(raw: unknown): string[] { ]; const paths: string[] = []; for (const candidate of candidates) { - if (typeof candidate !== 'string' || candidate.length === 0) continue; - if (!paths.includes(candidate)) paths.push(candidate); + // Only absolute paths are meaningful here: the legacy store was written by + // a different process with a different working directory, so a relative + // entry names nothing this Server can resolve. + if (typeof candidate !== 'string' || !path.isAbsolute(candidate)) continue; + const workspacePath = path.resolve(candidate); + if (!paths.includes(workspacePath)) paths.push(workspacePath); if (paths.length >= MAX_LEGACY_WORKSPACES) break; } return paths; } -export async function migrateLegacyDesktopWorkspaceStore( +/** Whether a remembered path still names a directory worth registering. */ +function isExistingDirectory(workspacePath: string): boolean { + try { + return statSync(workspacePath).isDirectory(); + } catch { + return false; + } +} + +export function migrateLegacyDesktopWorkspaceStore( filePath: string, dependencies: LegacyWorkspaceMigrationDependencies, -): Promise { +): void { if (dependencies.hasWorkspaceRegistry()) return; let paths: string[] = []; @@ -64,13 +99,35 @@ export async function migrateLegacyDesktopWorkspaceStore( let migrated = 0; let skipped = 0; - // `adopt()` records the current time, so import oldest-to-newest to preserve - // the legacy file's existing most-recent-first order. - for (const workspacePath of [...paths].reverse()) { + // Resolve what is importable in most-recent-first order, so that when two + // remembered paths compete the recent one wins. Restoring someone into a + // stale copy of their Workspace is worse than dropping the copy. + const importable: string[] = []; + const claimedIds = new Set(); + for (const workspacePath of paths) { + if (!isExistingDirectory(workspacePath)) { + // The folder is gone or its volume is not mounted. Registering it would + // resurrect an empty directory that reads as a real Workspace in the + // picker, so drop the entry instead. + skipped += 1; + continue; + } + const claimed = dependencies.workspaceIdentityOnDisk(workspacePath); + if (claimed) { + if (claimedIds.has(claimed.workspaceId)) { + skipped += 1; + continue; + } + claimedIds.add(claimed.workspaceId); + } + importable.push(workspacePath); + } + + // `adopt()` stamps the current time, so register oldest-to-newest to + // reproduce the legacy file's most-recent-first order as timestamps. + for (const workspacePath of importable.reverse()) { try { - const preparedPath = - await dependencies.prepareWorkspacePath(workspacePath); - dependencies.adoptWorkspaceDirectory(preparedPath); + dependencies.adoptWorkspaceDirectory(workspacePath); migrated += 1; } catch (error) { skipped += 1; diff --git a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts index 20da88786..90f65fa9e 100644 --- a/apps/server/src/modules/storage/backends/disk/workspace-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/workspace-repository.ts @@ -276,6 +276,27 @@ export function ensureWorkspaceManifestOnDisk( return readManifest(filePath); } +/** + * The identity a directory already claims, without adopting or writing it. + * + * Adoption assigns an identity when a folder has none, which makes it the + * wrong question to ask when a caller has to *choose between* directories — + * two remembered paths that turn out to be one copied Workspace, say, where + * registering the second is refused and the choice has to be made first. + * Unreachable and manifest-less directories both read as "claims nothing", + * because both are cases where adoption would mint a fresh identity. A + * malformed manifest still throws, as everywhere else. + */ +export function workspaceIdentityOnDisk( + rawWorkspacePath: string, +): WorkspaceHandle | null { + const manifest = readManifestFile( + manifestPath(path.resolve(rawWorkspacePath)), + true, + ); + return manifest ? toHandle(manifest) : null; +} + export class DiskWorkspaceRepository implements WorkspaceRepository { readonly #registryFilePath: string | null; /** diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 51ccec5b3..01dc65b64 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -52,7 +52,10 @@ export { ensureWorldCanvasOnDisk } from './backends/disk/world-canvas.js'; * work it exists to contain, while registry membership stays a Server-process * decision with exactly one writer. */ -export { ensureWorkspaceManifestOnDisk } from './backends/disk/workspace-repository.js'; +export { + ensureWorkspaceManifestOnDisk, + workspaceIdentityOnDisk, +} from './backends/disk/workspace-repository.js'; export { withCanvasMutex, updateNode } from '../canvas/write-coordinator.js'; export type { UpdateNodeOptions, diff --git a/apps/server/src/modules/workspaces.route.test.ts b/apps/server/src/modules/workspaces.route.test.ts index 18cca6991..f1af8821b 100644 --- a/apps/server/src/modules/workspaces.route.test.ts +++ b/apps/server/src/modules/workspaces.route.test.ts @@ -1,7 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -110,6 +116,12 @@ const locatorMocks = vi.hoisted(() => ({ } return { schemaVersion: 1, ...handleOf(member) }; }), + workspaceIdentityOnDisk: vi.fn((workspacePath: string) => { + const member = testState.diskIdentities.find( + (candidate) => candidate.workspacePath === workspacePath, + ); + return member ? handleOf(member) : null; + }), adoptWorkspaceDirectory: vi.fn((workspacePath: string) => { const identity = locatorMocks.ensureWorkspaceManifestOnDisk(workspacePath); const member: TestMember = { @@ -136,6 +148,7 @@ vi.mock('./storage/index.js', () => ({ ensureWorkspaceManifestOnDisk: locatorMocks.ensureWorkspaceManifestOnDisk, workspaceAtDirectory: locatorMocks.workspaceAtDirectory, workspaceDirectory: locatorMocks.workspaceDirectory, + workspaceIdentityOnDisk: locatorMocks.workspaceIdentityOnDisk, })); vi.mock('./workspace.js', () => ({ @@ -236,16 +249,22 @@ describe('plural Workspace management routes', () => { it('imports the deprecated desktop store before the first list', async () => { const root = mkdtempSync(path.join(tmpdir(), 'huabu-route-legacy-store-')); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + const deleted = path.join(root, 'deleted'); + mkdirSync(first); + mkdirSync(second); const legacyFile = path.join(root, 'workspace.json'); writeFileSync( legacyFile, - JSON.stringify({ - path: '/tmp/second', - recent: ['/tmp/second', '/tmp/first'], - }), + JSON.stringify({ path: second, recent: [second, first, deleted] }), 'utf8', ); testState.members = []; + testState.diskIdentities = [ + { workspaceId: FIRST_ID, workspacePath: first, name: 'First' }, + { workspaceId: SECOND_ID, workspacePath: second, name: 'Second' }, + ]; testState.registryInitialized = false; process.env.HUABU_LEGACY_WORKSPACE_STORE = legacyFile; const app = await buildApp(); @@ -256,10 +275,16 @@ describe('plural Workspace management routes', () => { expect( response.json().map((workspace: TestMember) => workspace.workspaceId), ).toEqual([SECOND_ID, FIRST_ID]); - expect(activationMocks.prepareWorkspacePath.mock.calls).toEqual([ - ['/tmp/first'], - ['/tmp/second'], - ]); + expect( + locatorMocks.adoptWorkspaceDirectory.mock.calls.map( + ([workspacePath]) => workspacePath, + ), + ).toEqual([first, second]); + // Registration never prepares: the collection must not be held behind a + // preparation fork per remembered folder, and a deleted folder must not + // be recreated by remembering it. + expect(activationMocks.prepareWorkspacePath).not.toHaveBeenCalled(); + expect(existsSync(deleted)).toBe(false); expect(testState.registryInitialized).toBe(true); } finally { await app.close(); @@ -268,6 +293,71 @@ describe('plural Workspace management routes', () => { } }); + it('attempts the deprecated desktop store once even when it registers nothing', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'huabu-route-legacy-once-')); + const home = path.join(root, 'home'); + mkdirSync(home); + const legacyFile = path.join(root, 'workspace.json'); + writeFileSync(legacyFile, JSON.stringify({ path: home }), 'utf8'); + testState.members = []; + testState.registryInitialized = false; + // Nothing gets registered, so `hasWorkspaceRegistry()` stays false and only + // the once-only flag can stop the import repeating on every later request. + const adoptImplementation = + locatorMocks.adoptWorkspaceDirectory.getMockImplementation(); + locatorMocks.adoptWorkspaceDirectory.mockImplementation(() => { + throw new Error('copied Workspace identity'); + }); + process.env.HUABU_LEGACY_WORKSPACE_STORE = legacyFile; + const app = await buildApp(); + try { + await app.inject({ method: 'GET', url: '/workspaces' }); + await app.inject({ method: 'GET', url: '/workspaces' }); + const last = await app.inject({ method: 'GET', url: '/workspaces' }); + + expect(last.statusCode).toBe(200); + expect(last.json()).toEqual([]); + expect(locatorMocks.adoptWorkspaceDirectory).toHaveBeenCalledTimes(1); + expect(testState.registryInitialized).toBe(false); + } finally { + await app.close(); + if (adoptImplementation) { + locatorMocks.adoptWorkspaceDirectory.mockImplementation( + adoptImplementation, + ); + } + delete process.env.HUABU_LEGACY_WORKSPACE_STORE; + rmSync(root, { recursive: true, force: true }); + } + }); + + it('never imports the deprecated desktop store in managed mode', async () => { + const root = mkdtempSync( + path.join(tmpdir(), 'huabu-route-legacy-managed-'), + ); + const home = path.join(root, 'home'); + mkdirSync(home); + const legacyFile = path.join(root, 'workspace.json'); + writeFileSync(legacyFile, JSON.stringify({ path: home }), 'utf8'); + testState.managed = true; + testState.registryInitialized = false; + process.env.HUABU_LEGACY_WORKSPACE_STORE = legacyFile; + const app = await buildApp(); + try { + const response = await app.inject({ method: 'GET', url: '/workspaces' }); + + expect(response.statusCode).toBe(200); + // Managed mode locks its Workspace at boot; a free-mode history file has + // nothing to say about it. + expect(locatorMocks.adoptWorkspaceDirectory).not.toHaveBeenCalled(); + expect(existsSync(path.join(home, '.workspace.json'))).toBe(false); + } finally { + await app.close(); + delete process.env.HUABU_LEGACY_WORKSPACE_STORE; + rmSync(root, { recursive: true, force: true }); + } + }); + it('registers and prepares a Workspace without activating it', async () => { const app = await buildApp(); try { diff --git a/apps/server/src/modules/workspaces.route.ts b/apps/server/src/modules/workspaces.route.ts index 02d3f0526..4adcba1bd 100644 --- a/apps/server/src/modules/workspaces.route.ts +++ b/apps/server/src/modules/workspaces.route.ts @@ -17,6 +17,7 @@ import { resetStorageCache, workspaceAtDirectory, workspaceDirectory, + workspaceIdentityOnDisk, } from './storage/index.js'; import { activateWorkspacePath, @@ -176,21 +177,32 @@ interface WorkspaceParams { } const workspacesRoutes: FastifyPluginAsync = async (app) => { - let legacyMigration: Promise | null = null; + let legacyDesktopStoreImported = false; - async function ensureLegacyDesktopStoreMigrated(): Promise { - if (isManagedMode() || hasWorkspaceRegistry()) return; + /** + * Import the deprecated desktop store once, before the first plural request + * is answered. Registration-only and synchronous, so it costs the triggering + * request a stat per remembered folder rather than holding the collection + * behind a preparation fork each. + */ + function importLegacyDesktopStore(): void { + if (legacyDesktopStoreImported || isManagedMode() || hasWorkspaceRegistry()) + return; const filePath = process.env.HUABU_LEGACY_WORKSPACE_STORE?.trim(); if (!filePath) return; - legacyMigration ??= migrateLegacyDesktopWorkspaceStore(filePath, { + // Mark before running: an import that throws is an import that happened, + // and retrying it on every later request would only repeat the failure. + legacyDesktopStoreImported = true; + migrateLegacyDesktopWorkspaceStore(filePath, { hasWorkspaceRegistry, - prepareWorkspacePath, adoptWorkspaceDirectory, + workspaceIdentityOnDisk, }); - await legacyMigration; } - app.addHook('preHandler', ensureLegacyDesktopStoreMigrated); + app.addHook('preHandler', async () => { + importLegacyDesktopStore(); + }); app.get('/', async () => (await visibleWorkspaces()).map(descriptor)); diff --git a/apps/web/src/store/workspaceStore.test.ts b/apps/web/src/store/workspaceStore.test.ts index 7b2b9deaf..1ffa3d16d 100644 --- a/apps/web/src/store/workspaceStore.test.ts +++ b/apps/web/src/store/workspaceStore.test.ts @@ -123,6 +123,29 @@ describe('workspaceStore registry persistence', () => { ).toEqual([SECOND_ID, FIRST_ID]); }); + it('keeps an already-activated Workspace when the registry cannot be listed', async () => { + apiState.info = { + ...unconfiguredInfo(), + configured: true, + workspaceId: FIRST_ID, + path: '/tmp/first', + name: 'First', + }; + apiMocks.listWorkspaces.mockRejectedValueOnce(new Error('registry broken')); + + await expect(useWorkspaceStore.getState().init()).resolves.toBe(true); + + // Only the welcome list degrades — the Server is activated and usable. + expect(apiMocks.activateWorkspace).not.toHaveBeenCalled(); + expect(useWorkspaceStore.getState()).toMatchObject({ + workspaceId: FIRST_ID, + isReady: true, + isSyncing: false, + error: null, + recentWorkspaces: [], + }); + }); + it('shares concurrent initialization so the MRU Workspace activates once', async () => { const first = useWorkspaceStore.getState().init(); const second = useWorkspaceStore.getState().init(); diff --git a/apps/web/src/store/workspaceStore.ts b/apps/web/src/store/workspaceStore.ts index af4130751..8fb679e9f 100644 --- a/apps/web/src/store/workspaceStore.ts +++ b/apps/web/src/store/workspaceStore.ts @@ -198,21 +198,28 @@ export const useWorkspaceStore = create()((set, get) => ({ } // ── Free mode ── - let registered: WorkspaceDescriptor[]; + // Server already activated (e.g. another tab beat us to it). + const activated = info.configured && Boolean(info.path); + + let registered: WorkspaceDescriptor[] = []; try { registered = await listWorkspaces(); set({ recentWorkspaces: registered }); } catch (err) { - set({ - error: - err instanceof Error ? err.message : 'Failed to list Workspaces', - isSyncing: false, - }); - return false; + // A registry we cannot read is only fatal when it is the sole route + // back to a Workspace. An already-activated Server stays usable; the + // welcome list is the one thing that degrades. + if (!activated) { + set({ + error: + err instanceof Error ? err.message : 'Failed to list Workspaces', + isSyncing: false, + }); + return false; + } } - // Server already activated (e.g. another tab beat us to it). - if (info.configured && info.path) { + if (activated) { set({ isSyncing: false }); emitWorkspaceChanged(); return true; diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 2c16acb94..911b31852 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -45,7 +45,7 @@ Runtime Home-folder activation reserves the namespace switch before preparing an Key points: -- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath }` entries in most-recently-used order. Successful adoption or activation promotes an entry to the front. It is the single in-process representation of membership and the welcome screen's source: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. +- `storage/disk/workspaces.json` is the Disk backend's discovery index and stores only `schemaVersion` plus `{ workspaceId, workspacePath, lastOpenedAt }` entries. Array order is stable and carries no meaning: successful adoption or activation stamps that entry's `lastOpenedAt`, and listings sort by it, so recency survives a hand-edited or re-serialized file. It is the single in-process representation of membership and the welcome screen's source: it is re-read from disk on every access and each member's display metadata is read back from its own `.workspace.json` on demand, so nothing here can go stale against the files it describes. The Server process is its only writer — the isolated preparation child adopts the manifest but never registers membership. - Electron's former `/workspace.json` path/recents store is deprecated. `start:desktop` passes its location to the Server as a read-only upgrade source; the first plural Workspace request imports its active path and recents only when `workspaces.json` does not yet exist. An existing registry always wins and the old file is never written again. - Opening an externally moved Workspace reads that manifest and replaces the indexed path for the same id. If it is active, the process-local active path moves with that registration before the API publishes the new location. Two physically distinct live paths carrying the same id are rejected as a copied-identity conflict; symlink aliases of one directory are the same materialization, not copies. A path whose folder was deleted and recreated is re-adopted under the identity now on disk, which is what keeps the legacy Home-folder selection flow working after the user rearranges folders outside Huabu. - A registered Workspace that cannot answer for itself — folder deleted, volume unmounted, path taken over by another Workspace — reads as "not a member right now" rather than an error, so one unreachable entry cannot take down the whole collection, and it returns when its volume does. A _malformed_ manifest or registry still throws: that is damage the operator has to see. Unregistering removes only the index entry, works while the Workspace is unreachable, and never deletes the Workspace directory or manifest.