From 44f04f9246734aba3dc064d6e5933c35d17d2b30 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 18:57:49 +0800 Subject: [PATCH 1/4] feat(storage): complete the portable node read surface and World bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SpaceNodes` had one read — by id — so every reader wanting more than one node had to go around the port to the legacy Disk store. Add the three shapes the application actually asks for: `readMany` for a named selection, `list` for work that genuinely spans the Space, and `stream` for a reader that can show partial results while the rest arrives. `readMany` is the one that matters for cost. Most readers want a handful of named nodes — a selection to describe, a neighbourhood to render, one View to serve — and expressing those as a whole-Space scan makes an unrelated node somewhere else part of the bill. Disk resolves each id through the same strict read `read()` uses, so a selection sees exactly what reading each id would, including the index rebuild that finds an externally renamed sidecar. `SpaceRepository.ensureWorld()` is the backend-neutral bootstrap hook. Every backend meets an empty namespace the first time it is mounted, and a Workspace with no World has no Portal target, so ensuring one cannot stay a Disk step run before the store exists. It delegates to the same idempotent Disk primitive Workspace preparation calls — one writer for one file, since the legacy preparation path still runs before the mount. The node contract now asserts the four read shapes never disagree about a node: an adapter whose scan parsed more leniently than its single read, or minted a different revision, would pass a suite written against one shape alone. The Space-collection contract covers both bootstrap branches, which needs a harness that can open a namespace nobody has mounted yet. Carried from the earlier phase 4.6 line (470437a4), which never reached a merged branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m --- .../modules/canvas/write-coordinator.test.ts | 20 ++++ .../storage/backends/disk/space-nodes.ts | 48 +++++++++ .../backends/disk/space-repository.test.ts | 11 +++ .../storage/backends/disk/space-repository.ts | 22 +++++ apps/server/src/modules/storage/index.ts | 1 + .../ports/contracts/space-nodes.contract.ts | 98 +++++++++++++++++++ .../contracts/space-repository.contract.ts | 57 +++++++++++ .../src/modules/storage/ports/structured.ts | 63 ++++++++++++ 8 files changed, 320 insertions(+) diff --git a/apps/server/src/modules/canvas/write-coordinator.test.ts b/apps/server/src/modules/canvas/write-coordinator.test.ts index a720b8a3a..1b9960f80 100644 --- a/apps/server/src/modules/canvas/write-coordinator.test.ts +++ b/apps/server/src/modules/canvas/write-coordinator.test.ts @@ -33,6 +33,26 @@ function fakeRepository(canvasId = 'c1') { if (revision === null) throw new Error('test storage token is missing'); return { record, revision }; }, + // This fake holds one node, so the collection reads are the same record + // under its own id. `updateNode` never calls them; they exist because the + // port has them. + async readMany(nodeIds): Promise> { + const snapshot = await nodes.read(''); + return snapshot !== null && nodeIds.includes(snapshot.record.nodeId) + ? new Map([[snapshot.record.nodeId, snapshot]]) + : new Map(); + }, + async list(): Promise> { + const snapshot = await nodes.read(''); + return snapshot === null + ? new Map() + : new Map([[snapshot.record.nodeId, snapshot]]); + }, + async stream(onNode): Promise> { + const all = await nodes.list(); + for (const snapshot of all.values()) onNode(snapshot); + return all; + }, async put(input) { if (suppressed) return { ok: false, reason: 'write-suppressed' }; const currentRevision = storageRevisionOf(record); diff --git a/apps/server/src/modules/storage/backends/disk/space-nodes.ts b/apps/server/src/modules/storage/backends/disk/space-nodes.ts index 759d07a1c..9fc2783f0 100644 --- a/apps/server/src/modules/storage/backends/disk/space-nodes.ts +++ b/apps/server/src/modules/storage/backends/disk/space-nodes.ts @@ -15,6 +15,7 @@ import type { NodePutInput, NodePutResult, NodeSnapshot, + NodeStreamOptions, SpaceNodes, } from '../../ports/structured.js'; @@ -30,6 +31,14 @@ function snapshotOf(record: NodeContent): NodeSnapshot { return { record, revision }; } +function snapshotsOf( + contents: ReadonlyMap, +): Map { + const out = new Map(); + for (const [nodeId, record] of contents) out.set(nodeId, snapshotOf(record)); + return out; +} + export class DiskSpaceNodes implements SpaceNodes { readonly canvasId: string; @@ -48,6 +57,45 @@ export class DiskSpaceNodes implements SpaceNodes { return record === null ? null : snapshotOf(record); } + /** + * Named nodes only. + * + * Resolved one id at a time through the same strict read {@link read} uses, + * so a selection sees exactly what reading each id would — including the + * index rebuild that resolves an externally renamed sidecar. Disk pays one + * directory scan to warm the id index and then one file read per requested + * id, which is the cost this member exists to keep proportional. Duplicate + * ids in the request collapse, as they do in the returned map. + */ + async readMany( + nodeIds: readonly string[], + ): Promise> { + this.#assertActiveWorkspace(); + const out = new Map(); + for (const nodeId of new Set(nodeIds)) { + const record = this.#store.readNodeStrict(nodeId); + if (record !== null) out.set(nodeId, snapshotOf(record)); + } + return out; + } + + async list(): Promise> { + this.#assertActiveWorkspace(); + return snapshotsOf(await this.#store.readAllNodes()); + } + + async stream( + onNode: (snapshot: NodeSnapshot) => void, + options?: NodeStreamOptions, + ): Promise> { + this.#assertActiveWorkspace(); + const contents = await this.#store.streamAllNodes( + (_id, content) => onNode(snapshotOf(content)), + options?.signal, + ); + return snapshotsOf(contents); + } + async put(input: NodePutInput): Promise { this.#assertActiveWorkspace(); if (input.record.nodeId !== input.nodeId) { diff --git a/apps/server/src/modules/storage/backends/disk/space-repository.test.ts b/apps/server/src/modules/storage/backends/disk/space-repository.test.ts index dd7ab747d..9f19aff10 100644 --- a/apps/server/src/modules/storage/backends/disk/space-repository.test.ts +++ b/apps/server/src/modules/storage/backends/disk/space-repository.test.ts @@ -115,6 +115,17 @@ describeSpaceRepositoryContract('Disk', () => { }, }), worldCanvasId: WORLD_ID, + // A Disk namespace is a Workspace directory, so an unmounted one is a + // fresh temp root with no `.world`. Activating it invalidates the store + // above, which is exactly the licence the harness member documents. + openEmptyNamespace: () => { + makeWorkspace('huabu-space-repository-contract-empty-'); + const empty = new DiskStructuredStore(); + return { + repository: empty.spaces(), + read: (canvasId: string) => empty.space(canvasId).read(), + }; + }, }; }); diff --git a/apps/server/src/modules/storage/backends/disk/space-repository.ts b/apps/server/src/modules/storage/backends/disk/space-repository.ts index e59b78fd2..ea8d30456 100644 --- a/apps/server/src/modules/storage/backends/disk/space-repository.ts +++ b/apps/server/src/modules/storage/backends/disk/space-repository.ts @@ -36,6 +36,7 @@ import { titleForAllocatedDirectory, titleVisibleAtDirectory, } from './space-title.js'; +import { ensureWorldCanvasOnDisk } from './world-canvas.js'; import { atomicWriteJson, mkdirp, sanitizeId } from '../../../../utils/fs.js'; import { normalizeForCompare } from '../../../../utils/naming.js'; import { getWorkspacePath } from '../../../workspace.js'; @@ -91,6 +92,27 @@ export class DiskSpaceRepository implements SpaceRepository { return this.#requireWorld(); } + /** + * Bootstrap the World, delegating to the same idempotent Disk primitive + * Workspace preparation calls. + * + * One writer for one file: the preparation path may still run before the + * store is mounted (legacy migrations remain filesystem-based), so a second + * implementation here would be a second authority over `.world/space.json` + * that could disagree about what counts as established. The primitive + * already reads an existing `.world` as established storage and raises on a + * malformed one, which is exactly what the port promises. + * + * The directory index is refreshed afterwards because a freshly created + * World is a new member of the collection every later read resolves through. + */ + async ensureWorld(): Promise { + this.#assertActiveWorkspace(); + const canvasId = ensureWorldCanvasOnDisk(this.#workspacePath); + refreshCanvasDirIndex(); + return canvasId; + } + async create(input: SpaceCreateInput): Promise { this.#assertActiveWorkspace(); const canvasId = sanitizeId(input.canvasId, 'canvasId'); diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 01dc65b64..01a1395f3 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -117,6 +117,7 @@ export type { NodePutInput, NodePutResult, NodeSnapshot, + NodeStreamOptions, SpaceBeginDeleteResult, SpaceChanges, SpaceCreateInput, diff --git a/apps/server/src/modules/storage/ports/contracts/space-nodes.contract.ts b/apps/server/src/modules/storage/ports/contracts/space-nodes.contract.ts index f6499cf9b..3d70bbd8f 100644 --- a/apps/server/src/modules/storage/ports/contracts/space-nodes.contract.ts +++ b/apps/server/src/modules/storage/ports/contracts/space-nodes.contract.ts @@ -66,6 +66,104 @@ export function describeSpaceNodesContract( await expect(repository.read('contract-missing')).resolves.toBeNull(); }); + /** + * The four read shapes are one view of one collection. + * + * Each is free to reach the backend differently — a lookup by id, a + * selection, a scan, a scan that yields — so what has to be asserted is + * that they never disagree about a node. An adapter whose scan parsed + * records more leniently than its single read, or minted a different + * revision, would pass every test written against one shape alone. + */ + it('agrees across read, readMany, list, and stream', async () => { + const { repository } = await open(); + const ids = ['contract-agree-a', 'contract-agree-b', 'contract-agree-c']; + for (const nodeId of ids) { + await putSuccessfully(repository, { + nodeId, + record: note(nodeId, `Contract ${nodeId}`, `body of ${nodeId}`), + }); + } + + const listed = await repository.list(); + for (const nodeId of ids) { + expect(listed.get(nodeId)).toEqual(await repository.read(nodeId)); + } + + const selection = ids.slice(0, 2); + const many = await repository.readMany(selection); + expect([...many.keys()].sort()).toEqual(selection); + for (const nodeId of selection) { + expect(many.get(nodeId)).toEqual(listed.get(nodeId)); + } + + const delivered: NodeSnapshot[] = []; + const streamed = await repository.stream((snapshot) => { + delivered.push(snapshot); + }); + expect(streamed).toEqual(listed); + // Delivery order is unspecified, so compare as a set keyed by id. + expect( + new Map( + delivered.map((snapshot) => [snapshot.record.nodeId, snapshot]), + ), + ).toEqual(listed); + expect(delivered).toHaveLength(listed.size); + }); + + it('omits absent ids from readMany rather than failing', async () => { + const { repository } = await open(); + const present = 'contract-partial-present'; + await putSuccessfully(repository, { + nodeId: present, + record: note(present, 'Contract partial', 'here'), + }); + + const many = await repository.readMany([ + present, + 'contract-partial-absent', + // A repeated id is one node, not two reads with two answers. + present, + ]); + + expect([...many.keys()]).toEqual([present]); + await expect(repository.readMany([])).resolves.toEqual(new Map()); + }); + + it('reads an absent Space as an empty collection', async () => { + const { missingRepository } = await open(); + + await expect(missingRepository.list()).resolves.toEqual(new Map()); + await expect( + missingRepository.readMany(['contract-missing-space-node']), + ).resolves.toEqual(new Map()); + + const delivered: NodeSnapshot[] = []; + await expect( + missingRepository.stream((snapshot) => delivered.push(snapshot)), + ).resolves.toEqual(new Map()); + expect(delivered).toEqual([]); + }); + + it('stops delivering to an aborted stream', async () => { + const { repository } = await open(); + const nodeId = 'contract-stream-abort'; + await putSuccessfully(repository, { + nodeId, + record: note(nodeId, 'Contract abort', 'body'), + }); + + const delivered: NodeSnapshot[] = []; + // Already aborted, so no adapter has an excuse to deliver: this pins + // that the promise still settles rather than that a mid-scan abort is + // observed at any particular node. + await repository.stream((snapshot) => delivered.push(snapshot), { + signal: { aborted: true }, + }); + + expect(delivered).toEqual([]); + }); + it('returns the exact persisted record and its matching revision from put', async () => { const { repository } = await open(); const input: NodePutInput = { diff --git a/apps/server/src/modules/storage/ports/contracts/space-repository.contract.ts b/apps/server/src/modules/storage/ports/contracts/space-repository.contract.ts index 32cdf2909..fb34ef2d0 100644 --- a/apps/server/src/modules/storage/ports/contracts/space-repository.contract.ts +++ b/apps/server/src/modules/storage/ports/contracts/space-repository.contract.ts @@ -23,9 +23,27 @@ export interface SpaceRepositoryContractHarness { readonly worldCanvasId: string; /** One representative portable mutation for deletion-fence checks. */ readonly attemptMutation: (canvasId: string) => Promise; + /** + * Bind to a namespace that has never been mounted — no World, no Spaces. + * + * This is the state every backend meets first, and the only place the + * creating branch of {@link SpaceRepository.ensureWorld} can be observed; + * the main harness namespace always has a World already. Invalidating the + * other harness members is allowed: a case that opens this uses nothing + * else afterwards. + */ + readonly openEmptyNamespace: () => + | Promise + | EmptyNamespaceHarness; readonly cleanup?: () => Promise | void; } +/** A backend namespace with nothing in it yet. */ +export interface EmptyNamespaceHarness { + readonly repository: SpaceRepository; + readonly read: (canvasId: string) => Promise; +} + export function describeSpaceRepositoryContract( name: string, createHarness: () => @@ -94,6 +112,45 @@ export function describeSpaceRepositoryContract( ).not.toContain(worldCanvasId); }); + it('returns the established World from ensureWorld without replacing it', async () => { + const { repository, worldCanvasId, read } = await open(); + const before = await read(worldCanvasId); + + await expect(repository.ensureWorld()).resolves.toBe(worldCanvasId); + await expect(repository.ensureWorld()).resolves.toBe(worldCanvasId); + + // Identity is what Portals reference, so a bootstrap that ran against an + // established World must be indistinguishable from not having run. + await expect(read(worldCanvasId)).resolves.toEqual(before); + expect( + (await repository.list()).map((row) => row.canvasId), + ).not.toContain(worldCanvasId); + }); + + it('bootstraps exactly one version-0 World in an empty namespace', async () => { + const { openEmptyNamespace } = await open(); + const { repository, read } = await openEmptyNamespace(); + + const worldCanvasId = await repository.ensureWorld(); + expect(worldCanvasId).toEqual(expect.any(String)); + expect(worldCanvasId).not.toHaveLength(0); + + const created = await read(worldCanvasId); + expect(created).toMatchObject({ + canvasId: worldCanvasId, + version: 0, + state: { nodes: [], edges: [] }, + }); + + // Idempotent, and the World it minted is the one `worldId()` resolves — + // a second bootstrap that minted a second identity would orphan every + // Portal written against the first. + await expect(repository.ensureWorld()).resolves.toBe(worldCanvasId); + await expect(repository.worldId()).resolves.toBe(worldCanvasId); + await expect(read(worldCanvasId)).resolves.toEqual(created); + await expect(repository.list()).resolves.toEqual([]); + }); + it('creates and returns the authoritative empty version-0 record', async () => { const { repository, read } = await open(); const result = await repository.create({ diff --git a/apps/server/src/modules/storage/ports/structured.ts b/apps/server/src/modules/storage/ports/structured.ts index 274074304..5ce4eed17 100644 --- a/apps/server/src/modules/storage/ports/structured.ts +++ b/apps/server/src/modules/storage/ports/structured.ts @@ -180,6 +180,21 @@ export interface SpaceRepository { * A missing or malformed World is an integrity failure and rejects. */ worldId(): Promise; + /** + * Return the stable World, creating it once if this namespace is new. + * + * The backend-neutral bootstrap hook. Every backend meets an empty + * namespace the first time it is mounted, and a Workspace without a World + * has no Portal target and no home view — so ensuring one cannot stay a + * Disk-shaped step run before the store exists. + * + * Idempotent, and deliberately narrower than "create if absent": it mints a + * version-0 World only when the namespace holds no World at all. An + * *established* World that is missing or malformed stays the integrity + * error {@link worldId} reports, because regenerating identity there would + * silently orphan every Portal that referenced it. + */ + ensureWorld(): Promise; create(input: SpaceCreateInput): Promise; /** * Fence mutations before cross-store cleanup begins. @@ -471,6 +486,54 @@ export interface SpaceNodes { */ readonly canvasId: string; read(nodeId: string): Promise; + /** + * The named nodes that exist, keyed by stable id. + * + * An absent id is a missing key, not an error: a caller asking for a + * selection is describing what it wants, not asserting that all of it is + * there. Reading the same id through {@link read} yields the same record and + * the same revision. + * + * This is the shape most readers want — a selection to describe, a + * neighbourhood to render, one View to serve — and it exists so their cost + * stays proportional to the request. Expressed as {@link list} the same read + * makes an unrelated node somewhere else part of the bill, and no backend + * serves that better than it serves a lookup by id. + */ + readMany(nodeIds: readonly string[]): Promise>; + /** + * Every node in this Space, keyed by stable id. + * + * For work that genuinely spans the Space — executor prestate hydration, + * the Space GET, the canvas outline, cross-node inspection. Iteration order + * is unspecified; a caller that needs an order imposes it. + */ + list(): Promise>; + /** + * {@link list}, delivering each record as it lands. + * + * `onNode` is invoked once per node, never concurrently with itself, before + * the returned map settles; the map is the same collection {@link list} + * would return. Delivery order is arrival order, and is deliberately not a + * query order — a backend may serve in whatever order is cheapest, and none + * of them promises a resumable cursor. This is a latency shape for a reader + * that can show partial results, not a pagination contract. + */ + stream( + onNode: (snapshot: NodeSnapshot) => void, + options?: NodeStreamOptions, + ): Promise>; put(input: NodePutInput): Promise; delete(nodeId: string): Promise; } + +export interface NodeStreamOptions { + /** + * Polled as the scan proceeds; an aborted scan stops delivering early. + * + * The returned promise still settles, so an adapter never leaks a pending + * scan, but its map is then partial by definition. A caller that aborts + * must check its own signal rather than reading the result. + */ + readonly signal?: { readonly aborted: boolean }; +} From 0aca5f84c78c7db366f276352a9324db8edc20a4 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 19:00:43 +0800 Subject: [PATCH 2/4] feat(storage): reach one Space through one handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching a Space meant calling two unrelated functions that never said they addressed the same thing: `getStructuredStore().space(id)` for its record and nodes, `canvasBlobs(id)` for its bytes, `spaceDirectory(id)` for its directory. Three entry points, one subject. `Storage.space(canvasId)` joins them, with a free `space()` shorthand on the barrel. It is a composition-layer facade, not a port type: `StructuredStore` and `BlobStore` keep their interfaces and their independence, and they are joined in the layer that already owns every cross-store rule — the blob-put precondition and the blob-first delete saga. The join cannot move down into a port, because the two axes are configured independently, deletion ordering deliberately keeps blob I/O outside any database transaction, and blob scopes exist that have no Space at all. The Disk directory becomes `diskTree`, typed by its absence rather than hidden behind a parallel import or stubbed to throw. A caller branching on `null` is told the truth once. It stays unportable and stays out of `ports/`: a backend that keeps Spaces in tables has no tree, and promising one would mean fabricating it. The fence is the Disk name plus the census in `module-boundaries.test.ts`, which may shrink and must not grow — every entry is a family §6.4.3 assigns a disposition. Two things fall out. `import-node-src` asked storage where a Space was in order to classify a path it had already resolved in sandbox coordinates; it now asks `fs-sandbox` for its own root, which is not a storage question at all. And `space()` composes from the receiver rather than a captured local, so `{...storage, blobs: fake}` — the obvious way to stub one axis, and what the artifact tests already do — gets Spaces built on the store it substituted instead of silently keeping the original. The four comments that named the retired `spaceDirectory()` now name the member that replaced it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m --- .../agent/conversation/prompt/attachments.ts | 4 +- .../src/modules/agent/memory/trigger.ts | 9 +- .../agent/tools/handlers/fs-sandbox.ts | 28 +++- .../agent/tools/handlers/image-generation.ts | 4 +- .../modules/artifact/artifact.route.test.ts | 16 +-- .../src/modules/artifact/artifact.route.ts | 10 +- apps/server/src/modules/artifact/utils.ts | 4 +- .../modules/canvas/canvas-executor.test.ts | 6 +- .../src/modules/canvas/canvas-executor.ts | 4 +- .../src/modules/canvas/canvas.route.test.ts | 6 +- .../server/src/modules/canvas/canvas.route.ts | 15 +- .../src/modules/canvas/external.route.ts | 11 +- .../modules/canvas/import-node-src.test.ts | 28 ++-- .../src/modules/canvas/import-node-src.ts | 9 +- .../src/modules/canvas/snapshot-nodes.ts | 10 +- .../interactive-view.route.ts | 4 +- .../interactive-view.service.ts | 8 +- .../src/modules/preprocessing/dispatcher.ts | 4 +- .../remote_fs/interactive-view.rfs.test.ts | 8 +- .../src/modules/remote_fs/rfs.route.test.ts | 20 ++- apps/server/src/modules/remote_fs/skill.ts | 15 +- .../modules/storage/backends/disk/layout.ts | 4 +- .../backends/disk/legacy/canvas-store.ts | 2 +- .../storage/backends/disk/space-tree.ts | 44 ++++++ .../backends/disk/storage-recovery.test.ts | 14 +- .../compatibility/delete-canvas.test.ts | 38 ++--- apps/server/src/modules/storage/index.ts | 7 +- .../modules/storage/module-boundaries.test.ts | 58 ++++++++ apps/server/src/modules/storage/paths.ts | 4 +- .../src/modules/storage/ports/workspace.ts | 5 +- apps/server/src/modules/storage/storage.ts | 136 +++++++++++++++--- .../legacy-workspace-activation.test.ts | 20 ++- apps/server/src/modules/workspace/paths.ts | 28 +++- 33 files changed, 432 insertions(+), 151 deletions(-) create mode 100644 apps/server/src/modules/storage/backends/disk/space-tree.ts diff --git a/apps/server/src/modules/agent/conversation/prompt/attachments.ts b/apps/server/src/modules/agent/conversation/prompt/attachments.ts index 6aab71eb0..3beedf85d 100644 --- a/apps/server/src/modules/agent/conversation/prompt/attachments.ts +++ b/apps/server/src/modules/agent/conversation/prompt/attachments.ts @@ -37,7 +37,7 @@ import { resolveImageUrl, MAX_INLINE_IMAGE_BYTES } from './image-inlining.js'; import { escapeXmlAttr, escapeXmlText } from './node-element.js'; import { isRasterizableImageMime } from '../../../../utils/mime.js'; import { ARTIFACT_URL_REGEX } from '../../../artifact/utils.js'; -import { canvasBlobs } from '../../../storage/index.js'; +import { space } from '../../../storage/index.js'; import type { AgentInputPart } from '@agenetes/protocol'; import type { ChatAttachment } from '@huabu/shared'; @@ -227,7 +227,7 @@ export async function buildAttachmentParts( if (resolvedCanvasId && resolvedFilename) { try { const bytes = - await canvasBlobs(resolvedCanvasId).read(resolvedFilename); + await space(resolvedCanvasId).blobs.read(resolvedFilename); // Attachments are inlined as text; binary bytes simply // decode to mojibake and the URL-only branch is used instead. if (bytes) fileContent = bytes.toString('utf-8'); diff --git a/apps/server/src/modules/agent/memory/trigger.ts b/apps/server/src/modules/agent/memory/trigger.ts index 6367f1c58..1b619b7ca 100644 --- a/apps/server/src/modules/agent/memory/trigger.ts +++ b/apps/server/src/modules/agent/memory/trigger.ts @@ -26,7 +26,7 @@ import { existsSync } from 'node:fs'; import { atomicWriteJson, mkdirp, readJson } from '../../../utils/fs.js'; import { createKeyedMutex } from '../../../utils/keyed-mutex.js'; -import { spaceDirectory } from '../../storage/index.js'; +import { space } from '../../storage/index.js'; import { memoryStatePath, canvasMemoryDir } from '../../workspace/paths.js'; /** Op-count threshold that triggers a memory analysis pass. */ @@ -83,7 +83,12 @@ export function writeMemoryState(canvasId: string, state: MemoryState): void { // file. Same hazard for any in-flight memory worker that calls // `markAnalyzed` post-delete. Skip the write when the canvas root // is gone; losing one bookkeeping write is harmless. - if (!existsSync(spaceDirectory(canvasId))) return; + // Disk-only by construction: the hazard is an ad-hoc file write + // recreating a directory the delete removed, and a backend with no + // directory has no such hazard. Phase 4.6 retires the guard entirely when + // this state moves onto the extension substrate (proposal §12.6.3). + const tree = space(canvasId).diskTree; + if (tree && !existsSync(tree.directory())) return; mkdirp(canvasMemoryDir(canvasId)); atomicWriteJson(memoryStatePath(canvasId), state); } diff --git a/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts b/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts index 18b80a5ea..62ef0b5d2 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts @@ -30,7 +30,7 @@ import { readFileSync, readdirSync, statSync, type Dirent } from 'node:fs'; import path from 'node:path'; import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js'; -import { getCanvasStore, spaceDirectory } from '../../../storage/index.js'; +import { getCanvasStore, space } from '../../../storage/index.js'; // ─── Always-skipped directory names ───────────────────────────────────────── @@ -143,7 +143,18 @@ export function safeResolve(canvasId: string, rel: string): string { ) { throw new Error(`Invalid canvasId: ${canvasId}`); } - const root = spaceDirectory(canvasId); + // The built-in file tools are Disk-only and stated as such (proposal + // §6.4.3, disposition A): off Disk the first-party agent reaches a Space + // over RFS/HTTP, which is what external agents already use. Refusing here + // is the backstop behind the capability matrix, not the primary check. + const tree = space(canvasId).diskTree; + if (!tree) { + throw new Error( + 'Built-in file tools need a Space directory, which the active ' + + 'structured backend does not provide.', + ); + } + const root = tree.directory(); // Accept the clean virtual prefixes (`upload/`, `artifacts/`) as aliases // for their hidden on-disk dirs so agents can reference either form. const target = path.resolve(root, toPhysicalRel(rel)); @@ -155,6 +166,19 @@ export function safeResolve(canvasId: string, rel: string): string { return target; } +/** + * The sandbox root for one Space. + * + * Exported because classifying a resolved path as "inside this Space" is a + * question about the sandbox, not about storage: the caller that asks is + * already working in sandbox coordinates, and routing it through storage + * would make it a consumer of a backend capability it has no stake in + * (proposal §6.4.3). + */ +export function sandboxRoot(canvasId: string): string { + return safeResolve(canvasId, ''); +} + /** Normalise a relative path to forward slashes. */ export function normalizeRel(rel: string): string { return rel.split(path.sep).join('/'); diff --git a/apps/server/src/modules/agent/tools/handlers/image-generation.ts b/apps/server/src/modules/agent/tools/handlers/image-generation.ts index 0c00a23d4..966913750 100644 --- a/apps/server/src/modules/agent/tools/handlers/image-generation.ts +++ b/apps/server/src/modules/agent/tools/handlers/image-generation.ts @@ -52,7 +52,7 @@ import { } from '@huabu/shared'; import { getLogger } from '../../../../utils/logger.js'; -import { canvasBlobs } from '../../../storage/index.js'; +import { space } from '../../../storage/index.js'; import { getAzureImageConfig } from '../../llm.js'; import type { generateImageParamsSchema } from '../definitions.js'; @@ -131,7 +131,7 @@ export async function handleGenerateImage( // ── Load reference artifacts upfront ────────────────────────────────── // Any missing/invalid ref is an early hard error — better than sending // a partial set to Azure and getting cryptic results. - const blobs = canvasBlobs(args.canvasId); + const blobs = space(args.canvasId).blobs; const refImages: Array<{ key: string; bytes: Buffer }> = []; for (const key of refs) { if (typeof key !== 'string' || !key.trim()) { diff --git a/apps/server/src/modules/artifact/artifact.route.test.ts b/apps/server/src/modules/artifact/artifact.route.test.ts index 3010fbec7..1bd48e187 100644 --- a/apps/server/src/modules/artifact/artifact.route.test.ts +++ b/apps/server/src/modules/artifact/artifact.route.test.ts @@ -23,7 +23,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import artifactRoute from './artifact.route.js'; import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js'; import { - canvasBlobs, + space, getStorage, resetStorageCache, setStorageForTesting, @@ -194,7 +194,7 @@ describe('artifact route', () => { }); expect(upload.statusCode).toBe(500); - expect(await canvasBlobs('missing').list()).toEqual([]); + expect(await space('missing').blobs.list()).toEqual([]); await app.close(); }); @@ -234,7 +234,7 @@ describe('artifact route', () => { const upload = await uploading; expect(upload.statusCode).toBe(500); expect(blocker.putCalls()).toBe(0); - expect(await canvasBlobs('c1').list()).toEqual([]); + expect(await space('c1').blobs.list()).toEqual([]); } finally { blocker.releaseDelete(); blocker.restore(); @@ -244,7 +244,7 @@ describe('artifact route', () => { it('serves a byte range so media nodes can seek', async () => { const app = await buildApp(); - await canvasBlobs('c1').put('a.png', png); + await space('c1').blobs.put('a.png', png); const res = await app.inject({ method: 'GET', @@ -260,7 +260,7 @@ describe('artifact route', () => { it('answers 304 for an unchanged artifact', async () => { const app = await buildApp(); - await canvasBlobs('c1').put('a.png', png); + await space('c1').blobs.put('a.png', png); const first = await app.inject({ method: 'GET', @@ -332,7 +332,7 @@ describe('artifact route', () => { it('clones an artifact into another canvas under a fresh key', async () => { const app = await buildApp(); - await canvasBlobs('src-canvas').put('a.png', png); + await space('src-canvas').blobs.put('a.png', png); const res = await app.inject({ method: 'POST', @@ -346,8 +346,8 @@ describe('artifact route', () => { expect(uri).toMatch(/\.png$/); // Destination owns its own copy; the source is untouched. - expect(await canvasBlobs('dst-canvas').read(uri)).toEqual(png); - expect(await canvasBlobs('src-canvas').read('a.png')).toEqual(png); + expect(await space('dst-canvas').blobs.read(uri)).toEqual(png); + expect(await space('src-canvas').blobs.read('a.png')).toEqual(png); await app.close(); }); diff --git a/apps/server/src/modules/artifact/artifact.route.ts b/apps/server/src/modules/artifact/artifact.route.ts index 26354399a..1d9a8f70e 100644 --- a/apps/server/src/modules/artifact/artifact.route.ts +++ b/apps/server/src/modules/artifact/artifact.route.ts @@ -8,7 +8,7 @@ import { type FastifyPluginAsync } from 'fastify'; import { cloneArtifactBodySchema, createId } from '@huabu/shared'; import { sendBlob } from './send-blob.js'; -import { canvasBlobs } from '../storage/index.js'; +import { space } from '../storage/index.js'; import { extractHtmlFromMhtml, injectBaseHref } from '../web/mhtml.js'; import type { @@ -61,7 +61,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { const name = `${id}${ext}`; try { - await canvasBlobs(canvasId).put(name, data.file); + await space(canvasId).blobs.put(name, data.file); } catch (error) { request.log.error({ err: error }, 'Failed to stream artifact to storage'); return reply.code(500).send({ message: 'Failed to save file' }); @@ -82,7 +82,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { '/:canvasId/artifact/:filename', async (request, reply) => { const { canvasId, filename } = request.params; - const blobs = canvasBlobs(canvasId); + const blobs = space(canvasId).blobs; const safeName = path.basename(filename); // `.mhtml` snapshots are stored as proper multipart/related MHTML @@ -151,7 +151,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { let buffer: Buffer | null; try { - buffer = await canvasBlobs(srcCanvasId).read(srcKey); + buffer = await space(srcCanvasId).blobs.read(srcKey); } catch (err) { request.log.error({ err }, 'Failed to read source artifact for clone'); return reply @@ -167,7 +167,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { const name = `${id}${ext}`; try { - await canvasBlobs(dstCanvasId).put(name, buffer); + await space(dstCanvasId).blobs.put(name, buffer); } catch (err) { request.log.error({ err }, 'Failed to clone artifact'); return reply diff --git a/apps/server/src/modules/artifact/utils.ts b/apps/server/src/modules/artifact/utils.ts index 58ab48d45..e27b69aa8 100644 --- a/apps/server/src/modules/artifact/utils.ts +++ b/apps/server/src/modules/artifact/utils.ts @@ -7,7 +7,7 @@ import { ARTIFACT_URL_REGEX } from '@huabu/shared'; import { getLogger } from '../../utils/logger.js'; import { IMAGE_MIME_MAP } from '../../utils/mime.js'; -import { canvasBlobs } from '../storage/index.js'; +import { space } from '../storage/index.js'; const log = getLogger('artifact'); @@ -56,7 +56,7 @@ export async function resolveArtifactImageUrl( if (!canvasId || !filename) return url; try { - const buffer = await canvasBlobs(canvasId).read(filename); + const buffer = await space(canvasId).blobs.read(filename); if (!buffer) return url; const ext = path.extname(filename).toLowerCase(); // Never guess `image/png` for an unknown extension: callers forward this diff --git a/apps/server/src/modules/canvas/canvas-executor.test.ts b/apps/server/src/modules/canvas/canvas-executor.test.ts index 8bf897503..67bd529f7 100644 --- a/apps/server/src/modules/canvas/canvas-executor.test.ts +++ b/apps/server/src/modules/canvas/canvas-executor.test.ts @@ -30,7 +30,7 @@ import { nodeRevisionOf } from '@huabu/shared/canvas-engine'; import { applyDeltasOnServer, executeOnServer } from './canvas-executor.js'; import { - canvasBlobs, + space, getCanvasStore, getStructuredStore, updateNode, @@ -273,7 +273,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => { src: 'old.svg', content: '', }); - await canvasBlobs('c1').put( + await space('c1').blobs.put( 'new.svg', Buffer.from( '', @@ -329,7 +329,7 @@ describe('executeOnServer — MERGE_NODE_DATA CAS', () => { src: 'pic.svg', content: '', }); - await canvasBlobs('c1').put( + await space('c1').blobs.put( 'pic.svg', Buffer.from( '', diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts index 30461bec6..a59a62b46 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -64,7 +64,7 @@ import { } from './world-portal-policy.js'; import { getLogger } from '../../utils/logger.js'; import { - canvasBlobs, + space, getCanvasStore, getStructuredStore, withCanvasMutex, @@ -375,7 +375,7 @@ async function aspectHeightForWidth( width: number, ): Promise { try { - const dim = await readImageDimensions(canvasBlobs(canvasId), src); + const dim = await readImageDimensions(space(canvasId).blobs, src); if (!dim?.width || !dim?.height || dim.width <= 0 || dim.height <= 0) { return null; } diff --git a/apps/server/src/modules/canvas/canvas.route.test.ts b/apps/server/src/modules/canvas/canvas.route.test.ts index bbfeac7a8..c34a7a81a 100644 --- a/apps/server/src/modules/canvas/canvas.route.test.ts +++ b/apps/server/src/modules/canvas/canvas.route.test.ts @@ -39,7 +39,7 @@ import canvasRoutes from './canvas.route.js'; import { withSpaceDirHandlesReleased } from '../storage/backends/disk/space-dir-handles.js'; import { createCanvas, deleteCanvas } from '../storage/compatibility/canvas.js'; import { - canvasBlobs, + space, getCanvasStore, getStructuredStore, resetStorageCache, @@ -738,7 +738,7 @@ describe('Space export/import persistence', () => { change, ]); const blob = Buffer.from([0, 1, 2, 3, 255]); - await canvasBlobs('c1').put('asset.bin', blob); + await space('c1').blobs.put('asset.bin', blob); const app = await buildApp(); try { @@ -790,7 +790,7 @@ describe('Space export/import persistence', () => { expect(await importedSpace.changes.read('thread-export')).toEqual( storedChanges, ); - expect(await canvasBlobs(importedId).read('asset.bin')).toEqual(blob); + expect(await space(importedId).blobs.read('asset.bin')).toEqual(blob); } finally { await app.close(); } diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index 38b531fdb..de99b02de 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -58,12 +58,11 @@ import { suggestCanvasDir, } from '../storage/canvas-dirs.js'; import { - canvasBlobs, + space, createSpace, deleteSpace, getCanvasStore, getStructuredStore, - spaceDirectory, type CanvasFile, type UpdateNodeOutcome, updateNode, @@ -342,7 +341,7 @@ async function singleArtifactProbe( ): Promise<(key: string) => boolean> { const key = extractArtifactKey(src); if (!key) return () => false; - const exists = (await canvasBlobs(canvasId).hasMany([key])).has(key); + const exists = (await space(canvasId).blobs.hasMany([key])).has(key); return (candidate) => candidate === key && exists; } @@ -532,7 +531,7 @@ async function hydrateNodeContent( const present = referenced.size === 0 ? new Set() - : await canvasBlobs(store.canvasId).hasMany([...referenced]); + : await space(store.canvasId).blobs.hasMany([...referenced]); const artifactExists = (key: string): boolean => present.has(key); return nodes.map((node) => { @@ -1629,8 +1628,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { return reply.code(404).send({ message: 'Canvas not found' }); } - const canvasDir = spaceDirectory(canvasId); - if (!existsSync(canvasDir)) { + // The Space bundle is a Disk projection (proposal §6.4.3, disposition + // A); a portable export generated from records plus reachable blob + // references is a separate later design. + const tree = space(canvasId).diskTree; + const canvasDir = tree?.directory(); + if (canvasDir === undefined || !existsSync(canvasDir)) { return reply.code(404).send({ message: 'Canvas directory not found' }); } diff --git a/apps/server/src/modules/canvas/external.route.ts b/apps/server/src/modules/canvas/external.route.ts index 0984daa1c..6460a9ad8 100644 --- a/apps/server/src/modules/canvas/external.route.ts +++ b/apps/server/src/modules/canvas/external.route.ts @@ -17,7 +17,7 @@ import { takeExternalNote, } from './external-watcher.js'; import { parseFrontmatter } from '../../utils/markdown-frontmatter.js'; -import { spaceDirectory } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { FastifyPluginAsync } from 'fastify'; @@ -95,7 +95,14 @@ const externalRoutes: FastifyPluginAsync = async (fastify): Promise => { return reply.code(404).send({ message: 'External note not found' }); } - const abs = path.join(spaceDirectory(canvasId), item.relativePath); + // External-note claim is Disk-only (proposal §6.4.3, disposition A): it + // exists to adopt documents that arrived without going through the + // application, and no database backend has such an arrival path. + const tree = space(canvasId).diskTree; + if (!tree) { + return reply.code(404).send({ message: 'External note not found' }); + } + const abs = path.join(tree.directory(), item.relativePath); let raw: string; try { raw = await readFile(abs, 'utf8'); diff --git a/apps/server/src/modules/canvas/import-node-src.test.ts b/apps/server/src/modules/canvas/import-node-src.test.ts index 089ad92ac..731608357 100644 --- a/apps/server/src/modules/canvas/import-node-src.test.ts +++ b/apps/server/src/modules/canvas/import-node-src.test.ts @@ -15,15 +15,23 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { importForeignNodeSources } from './import-node-src.js'; import { createCanvas } from '../storage/compatibility/canvas.js'; -import { - canvasBlobs, - getCanvasStore, - spaceDirectory, -} from '../storage/index.js'; +import { space, getCanvasStore } from '../storage/index.js'; import { setWorkspacePath } from '../workspace.js'; import type { CanvasCommand } from '@huabu/shared'; +/** + * The Space's Disk directory, or a test failure. + * + * These cases are Disk-specific by construction; the assertion states that + * rather than letting an optional-chained `undefined` quietly pass. + */ +function diskDirOf(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('Expected the Disk backend in this test'); + return tree.directory(); +} + let tmp: string; beforeEach(() => { @@ -49,7 +57,7 @@ afterEach(() => { /** Stage a file under the canvas's hidden `.upload/` scratch dir. */ function stageUpload(canvasId: string, name: string, body: string): string { - const uploadDir = path.join(spaceDirectory(canvasId), '.upload'); + const uploadDir = path.join(diskDirOf(canvasId), '.upload'); mkdirSync(uploadDir, { recursive: true }); const abs = path.join(uploadDir, name); writeFileSync(abs, body); @@ -125,7 +133,7 @@ describe('importForeignNodeSources — web nodes', () => { // …whose file exists in the artifact store… expect(src).toBeDefined(); if (src === undefined) throw new Error('Expected a rewritten web src'); - expect(await canvasBlobs(canvasId).head(src)).not.toBeNull(); + expect(await space(canvasId).blobs.head(src)).not.toBeNull(); // …and the staging upload was reclaimed (move semantics). expect(existsSync(uploadAbs)).toBe(false); }); @@ -221,7 +229,7 @@ describe('importForeignNodeSources — web nodes', () => { expect(src).toMatch(/^artifact-[^/]+\.html$/); expect(src).toBeDefined(); if (src === undefined) throw new Error('Expected a rewritten web src'); - expect(await canvasBlobs(canvasId).head(src)).not.toBeNull(); + expect(await space(canvasId).blobs.head(src)).not.toBeNull(); expect(existsSync(uploadAbs)).toBe(false); }); @@ -268,13 +276,13 @@ describe('importForeignNodeSources — media nodes (regression)', () => { expect(src).toMatch(/^artifact-[^/]+\.png$/); expect(src).toBeDefined(); if (src === undefined) throw new Error('Expected a rewritten image src'); - expect(await canvasBlobs(canvasId).head(src)).not.toBeNull(); + expect(await space(canvasId).blobs.head(src)).not.toBeNull(); }); it('canonicalizes an artifact path that leaves and re-enters the Space', async () => { const canvasId = 'c-image-reentered'; const store = getCanvasStore(canvasId); - const spaceDir = spaceDirectory(canvasId); + const spaceDir = diskDirOf(canvasId); const artifactsDir = path.join(spaceDir, '.artifacts'); mkdirSync(artifactsDir, { recursive: true }); writeFileSync(path.join(artifactsDir, 'pic.png'), 'existing artifact'); diff --git a/apps/server/src/modules/canvas/import-node-src.ts b/apps/server/src/modules/canvas/import-node-src.ts index 39837f701..77f39c27f 100644 --- a/apps/server/src/modules/canvas/import-node-src.ts +++ b/apps/server/src/modules/canvas/import-node-src.ts @@ -39,9 +39,10 @@ import { getLogger } from '../../utils/logger.js'; import { safeResolve, isArtifactsRel, + sandboxRoot, toPhysicalRel, } from '../agent/tools/handlers/fs-sandbox.js'; -import { canvasBlobs, spaceDirectory } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { CanvasStore } from '../storage/index.js'; @@ -272,7 +273,7 @@ async function resolveImportedSrc( // is judged by where it actually lands, while the helper still owns the // virtual/physical `.artifacts` vocabulary. A nested path is not a blob key, // so it falls through and is copied into the artifact root below. - const resolvedPhysicalRel = path.relative(spaceDirectory(canvasId), absPath); + const resolvedPhysicalRel = path.relative(sandboxRoot(canvasId), absPath); if (isArtifactsRel(resolvedPhysicalRel)) { const key = path.basename(absPath); const canonicalPath = safeResolve( @@ -309,7 +310,7 @@ async function copyToArtifact( const id = createId('artifact'); const key = `${id}${ext}`; const buffer = await readFile(absPath); - await canvasBlobs(store.canvasId).put(key, buffer); + await space(store.canvasId).blobs.put(key, buffer); // Move semantics: reclaim RFS scratch uploads once they are safely // stored. Never delete user node files or other canvas content — @@ -365,7 +366,7 @@ async function downloadToArtifact( } const ext = pickDownloadExt(pathname, contentType); const key = `${createId('artifact')}${ext}`; - await canvasBlobs(store.canvasId).put(key, buffer); + await space(store.canvasId).blobs.put(key, buffer); return key; } catch (err) { log.warn({ err, url }, 'Failed to download online node src into artifacts'); diff --git a/apps/server/src/modules/canvas/snapshot-nodes.ts b/apps/server/src/modules/canvas/snapshot-nodes.ts index 9d79bbdc2..356f5c048 100644 --- a/apps/server/src/modules/canvas/snapshot-nodes.ts +++ b/apps/server/src/modules/canvas/snapshot-nodes.ts @@ -68,7 +68,7 @@ import { import { getSketchRenderedSize } from '@huabu/shared/canvas-engine'; import { RASTERIZABLE_IMAGE_EXT_MIME } from '../../utils/mime.js'; -import { canvasBlobs, getCanvasStore } from '../storage/index.js'; +import { space, getCanvasStore } from '../storage/index.js'; import type { SketchNodeData, @@ -450,7 +450,7 @@ async function loadContextImage( if (!mimeType) return null; const { width, height } = nodeBoxSize(node); if (width <= 0 || height <= 0) return null; - const bytes = await canvasBlobs(store.canvasId).read(src); + const bytes = await space(store.canvasId).blobs.read(src); if (!bytes) return null; return { node, resolvedSrc: src, bytes, mimeType, width, height }; } @@ -799,7 +799,7 @@ async function maybeResizeImageArtifact( src: string, maxEdge: number, ): Promise<{ src: string; width: number; height: number } | null> { - const blobs = canvasBlobs(store.canvasId); + const blobs = space(store.canvasId).blobs; const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; if (!mimeType) return null; @@ -1060,10 +1060,10 @@ export async function snapshotNodesToArtifacts( ? `sketch-raster-${fingerprint}` : `sketch-raster-${fingerprint}-${maxEdge}`; const filename = `${id}.png`; - const existing = await canvasBlobs(store.canvasId).head(filename); + const existing = await space(store.canvasId).blobs.head(filename); if (!existing) { const png = await renderClusterPng(built.svg, built.width); - await canvasBlobs(store.canvasId).put(filename, png); + await space(store.canvasId).blobs.put(filename, png); } results.push({ src: filename, diff --git a/apps/server/src/modules/interactive-view/interactive-view.route.ts b/apps/server/src/modules/interactive-view/interactive-view.route.ts index 0e8124c97..a404fe0e8 100644 --- a/apps/server/src/modules/interactive-view/interactive-view.route.ts +++ b/apps/server/src/modules/interactive-view/interactive-view.route.ts @@ -20,7 +20,7 @@ import { interactiveViewService, } from './interactive-view.service.js'; import { sendBlob } from '../artifact/send-blob.js'; -import { canvasBlobs } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { FastifyPluginAsync } from 'fastify'; @@ -84,7 +84,7 @@ const interactiveViewRoutes: FastifyPluginAsync = async (app) => { const sent = await sendBlob( request, reply, - canvasBlobs(params.data.canvasId), + space(params.data.canvasId).blobs, resource.rendererArtifact, ); if (!sent) { diff --git a/apps/server/src/modules/interactive-view/interactive-view.service.ts b/apps/server/src/modules/interactive-view/interactive-view.service.ts index 7d91903e9..251acdf3b 100644 --- a/apps/server/src/modules/interactive-view/interactive-view.service.ts +++ b/apps/server/src/modules/interactive-view/interactive-view.service.ts @@ -32,11 +32,7 @@ import { executeOnServer, type InteractiveViewConflict, } from '../canvas/canvas-executor.js'; -import { - canvasBlobs, - getCanvasStore, - getStructuredStore, -} from '../storage/index.js'; +import { space, getCanvasStore, getStructuredStore } from '../storage/index.js'; import type { FastifyBaseLogger } from 'fastify'; @@ -346,7 +342,7 @@ export class InteractiveViewService { : null; const rendererExists = request.rendererArtifact.startsWith('upload/') ? stagedPath !== null && existsSync(stagedPath) - : Boolean(await canvasBlobs(canvasId).head(request.rendererArtifact)); + : Boolean(await space(canvasId).blobs.head(request.rendererArtifact)); if (!rendererExists) { throw new InteractiveViewServiceError( 'renderer_not_found', diff --git a/apps/server/src/modules/preprocessing/dispatcher.ts b/apps/server/src/modules/preprocessing/dispatcher.ts index c15fb02e2..1f1652bb2 100644 --- a/apps/server/src/modules/preprocessing/dispatcher.ts +++ b/apps/server/src/modules/preprocessing/dispatcher.ts @@ -14,7 +14,7 @@ import { isLabelProtected } from './label-policy.js'; import { runPipeline, type PipelineDeps } from './pipeline.js'; import { getProfile } from './profiles.js'; import { ProviderManager } from './provider-manager.js'; -import { canvasBlobs, getStructuredStore } from '../storage/index.js'; +import { space, getStructuredStore } from '../storage/index.js'; import type { Capability, @@ -166,7 +166,7 @@ export class PreprocessDispatcher { const deps: PipelineDeps = { nodes: getStructuredStore().space(request.canvasId).nodes, - blobs: canvasBlobs(request.canvasId), + blobs: space(request.canvasId).blobs, provider: this.provider, }; diff --git a/apps/server/src/modules/remote_fs/interactive-view.rfs.test.ts b/apps/server/src/modules/remote_fs/interactive-view.rfs.test.ts index 934ca5e9c..3f41c9b06 100644 --- a/apps/server/src/modules/remote_fs/interactive-view.rfs.test.ts +++ b/apps/server/src/modules/remote_fs/interactive-view.rfs.test.ts @@ -17,11 +17,7 @@ import { type AcpWorkloadSpec, } from '../agent/agenetes/drivers.js'; import interactiveViewRoutes from '../interactive-view/interactive-view.route.js'; -import { - canvasBlobs, - getCanvasStore, - resetStorageCache, -} from '../storage/index.js'; +import { space, getCanvasStore, resetStorageCache } from '../storage/index.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; import { setWorkspacePath } from '../workspace.js'; @@ -220,7 +216,7 @@ describe('Interactive View RFS resources', () => { code: 'renderer_not_found', }); - await canvasBlobs('c1').put('view.html', Buffer.from('

view

')); + await space('c1').blobs.put('view.html', Buffer.from('

view

')); const invalidState = await app.inject({ method: 'POST', url: '/rfs/c1/interactive-views', diff --git a/apps/server/src/modules/remote_fs/rfs.route.test.ts b/apps/server/src/modules/remote_fs/rfs.route.test.ts index 600dc914e..b9c9a3520 100644 --- a/apps/server/src/modules/remote_fs/rfs.route.test.ts +++ b/apps/server/src/modules/remote_fs/rfs.route.test.ts @@ -56,11 +56,7 @@ import { agentThreadService, } from '../agent/agent-thread.service.js'; import * as selectableProfiles from '../agent/selectable-agent-profile.js'; -import { - getCanvasStore, - resetStorageCache, - spaceDirectory, -} from '../storage/index.js'; +import { getCanvasStore, resetStorageCache, space } from '../storage/index.js'; import { RunCompletionError, runCompletionService, @@ -72,6 +68,18 @@ import { setWorkspacePath } from '../workspace.js'; import type { FixedAgentNodeTarget } from '../agent/agent-thread-resolver.js'; import type { CanvasNodeId } from '@huabu/shared'; +/** + * The Space's Disk directory, or a test failure. + * + * These cases are Disk-specific by construction; the assertion states that + * rather than letting an optional-chained `undefined` quietly pass. + */ +function diskDirOf(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('Expected the Disk backend in this test'); + return tree.directory(); +} + let tmp: string; async function buildApp() { @@ -159,7 +167,7 @@ describe('GET /api/rfs/:canvasId/skill', () => { it('returns only the bundled root guide without authorization', async () => { seedNote('c1', 'node-1', 'Anchor', 'content'); writeFileSync( - join(spaceDirectory('c1'), 'skill.md'), + join(diskDirOf('c1'), 'skill.md'), '# Private Space Override', 'utf8', ); diff --git a/apps/server/src/modules/remote_fs/skill.ts b/apps/server/src/modules/remote_fs/skill.ts index 7dcbb5c97..213dd690a 100644 --- a/apps/server/src/modules/remote_fs/skill.ts +++ b/apps/server/src/modules/remote_fs/skill.ts @@ -15,7 +15,7 @@ import { existsSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { renderPromptFile } from '../../prompt/agents/loader.js'; -import { spaceDirectory } from '../storage/index.js'; +import { space } from '../storage/index.js'; /** PROMPT-ROOT-relative path of the bundled access guide. */ const ACCESS_GUIDE_TEMPLATE = 'external-agent/access-huabu.md'; @@ -40,9 +40,16 @@ export function resolveBundledRootSkill(): string { * markdown text (served with `Content-Type: text/markdown`). */ export function resolveCanvasSkill(canvasId: string): string { - const override = path.join(spaceDirectory(canvasId), 'skill.md'); - if (existsSync(override)) { - return readFileSync(override, 'utf8'); + // A user-authored override read from the Space root. Disposition D + // (proposal §6.4.3): it becomes a blob under its own scope kind, at which + // point this reads through the port and the branch goes away. Until then a + // backend without a directory simply has no override to find. + const tree = space(canvasId).diskTree; + if (tree) { + const override = path.join(tree.directory(), 'skill.md'); + if (existsSync(override)) { + return readFileSync(override, 'utf8'); + } } return resolveBundledRootSkill(); } diff --git a/apps/server/src/modules/storage/backends/disk/layout.ts b/apps/server/src/modules/storage/backends/disk/layout.ts index fe2a400ac..35afbdbd6 100644 --- a/apps/server/src/modules/storage/backends/disk/layout.ts +++ b/apps/server/src/modules/storage/backends/disk/layout.ts @@ -36,8 +36,8 @@ import { getWorkspacePath } from '../../../workspace.js'; * * Resolved through {@link canvasDirName} rather than the canvasId, because * Disk files a Space under its title and that name moves on rename. This is - * also the materialization anchor the rest of the app reaches by way of - * `storage`'s `spaceDirectory()`. + * also the materialization anchor the rest of the app reaches by way of the + * Space handle's `diskTree` member. */ export function canvasRoot(canvasId: string): string { const safeId = sanitizeId(canvasId, 'canvasId'); diff --git a/apps/server/src/modules/storage/backends/disk/legacy/canvas-store.ts b/apps/server/src/modules/storage/backends/disk/legacy/canvas-store.ts index d020fd9bd..7d9b5d429 100644 --- a/apps/server/src/modules/storage/backends/disk/legacy/canvas-store.ts +++ b/apps/server/src/modules/storage/backends/disk/legacy/canvas-store.ts @@ -1288,7 +1288,7 @@ export class CanvasStore { // ── Artifacts ──────────────────────────────────────────────────────────── // // Artifact bytes are NOT owned here. They live behind the `BlobStore` - // port — `canvasBlobs(canvasId)` in `storage.js` — so this store holds + // port — `space(canvasId).blobs` in `storage.js` — so this store holds // structured records only and a non-filesystem blob backend can be // configured independently. See docs/proposals/multi-backend-storage.md. diff --git a/apps/server/src/modules/storage/backends/disk/space-tree.ts b/apps/server/src/modules/storage/backends/disk/space-tree.ts new file mode 100644 index 000000000..a11e1fb01 --- /dev/null +++ b/apps/server/src/modules/storage/backends/disk/space-tree.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * The Disk backend's real directory for one Space. + * + * **Not a port, and deliberately not in `ports/`.** `StructuredStore` and + * `BlobStore` are the whole portable surface. A Space directory is Disk's, and + * a backend that keeps Spaces in tables does not have one; making every + * backend promise a directory would mean fabricating one, which moves the + * failure somewhere less obvious than the refusal + * (docs/proposals/multi-backend-storage.md §6.4.1, §12.6.2). + * + * It reaches consumers as `space(canvasId).diskTree`, typed by its absence — + * `null` on any other backend — so a caller is told the truth once, at the + * same handle it asks every other storage question. The fence that keeps an + * unportable capability from reading as a portable one is the name and the + * enumerated consumer list in `module-boundaries.test.ts`, not the shape of + * the accessor. + */ + +import { canvasRoot } from './layout.js'; + +export interface DiskSpaceTree { + readonly canvasId: string; + /** + * Absolute path to this Space's directory in the active Workspace. + * + * A method rather than a property because it is not a constant: the + * directory name is derived from the Space's title, so a Finder-side rename + * moves it, and a Workspace switch invalidates it entirely. Resolving per + * call keeps a retained tree from handing back a path that was true when the + * handle was made. It raises rather than improvising when the id is + * malformed or the resolved path escapes the Workspace. + */ + directory(): string; +} + +export function diskSpaceTree(canvasId: string): DiskSpaceTree { + return { + canvasId, + directory: () => canvasRoot(canvasId), + }; +} diff --git a/apps/server/src/modules/storage/backends/disk/storage-recovery.test.ts b/apps/server/src/modules/storage/backends/disk/storage-recovery.test.ts index 26bac81f9..3f02d662b 100644 --- a/apps/server/src/modules/storage/backends/disk/storage-recovery.test.ts +++ b/apps/server/src/modules/storage/backends/disk/storage-recovery.test.ts @@ -32,11 +32,7 @@ import { resetStorageCache, } from './legacy/canvas-store-cache.js'; import { DiskStructuredStore } from './structured-store.js'; -import { - canvasBlobs, - createStorage, - setStorageForTesting, -} from '../../storage.js'; +import { space, createStorage, setStorageForTesting } from '../../storage.js'; import type { CanvasFile } from '../../../canvas/persistence-types.js'; @@ -468,7 +464,7 @@ describe('Space lifecycle guards and reopen', () => { handle.changes.append('thread-1', [change('n1')]), ).rejects.toThrow(/missing Space/); await expect( - canvasBlobs('missing-space').put('x.bin', rejectedBuffer), + space('missing-space').blobs.put('x.bin', rejectedBuffer), ).rejects.toThrow(/missing Space/); expect(rejectedBuffer.toString()).toBe('x'); @@ -480,7 +476,7 @@ describe('Space lifecycle guards and reopen', () => { const ended = once(body, 'end'); await expect( - canvasBlobs('missing-stream-space').put('x.bin', body), + space('missing-stream-space').blobs.put('x.bin', body), ).rejects.toThrow(/missing Space/); await ended; @@ -505,7 +501,7 @@ describe('Space lifecycle guards and reopen', () => { const storedChanges = await first.changes.append('thread-1', [ change('n1'), ]); - await canvasBlobs('reopen').put('payload.bin', Buffer.from('persisted')); + await space('reopen').blobs.put('payload.bin', Buffer.from('persisted')); resetStorageCache(); const reopened = new DiskStructuredStore().space('reopen'); @@ -516,7 +512,7 @@ describe('Space lifecycle guards and reopen', () => { 7, ]); expect(await reopened.changes.read('thread-1')).toEqual(storedChanges); - expect((await canvasBlobs('reopen').read('payload.bin'))?.toString()).toBe( + expect((await space('reopen').blobs.read('payload.bin'))?.toString()).toBe( 'persisted', ); }); diff --git a/apps/server/src/modules/storage/compatibility/delete-canvas.test.ts b/apps/server/src/modules/storage/compatibility/delete-canvas.test.ts index cbcda58cc..741182470 100644 --- a/apps/server/src/modules/storage/compatibility/delete-canvas.test.ts +++ b/apps/server/src/modules/storage/compatibility/delete-canvas.test.ts @@ -21,10 +21,10 @@ import { resetStorageCache } from '../backends/disk/legacy/canvas-store-cache.js import { DiskStructuredStore } from '../backends/disk/structured-store.js'; import { getCanvasStore } from '../index.js'; import { - canvasBlobs, + composeStorage, + space, deleteSpace, setStorageForTesting, - type Storage, } from '../storage.js'; import type { @@ -195,11 +195,13 @@ let restoreStorage: () => void; function installBlobStore(next: BlobStore): void { restoreStorage(); - restoreStorage = setStorageForTesting({ - profile: { structured: { kind: 'disk' }, blobs: { kind: 'disk' } }, - structured: new DiskStructuredStore(), - blobs: next, - } satisfies Storage); + restoreStorage = setStorageForTesting( + composeStorage( + { structured: { kind: 'disk' }, blobs: { kind: 'disk' } }, + new DiskStructuredStore(), + next, + ), + ); } beforeEach(() => { @@ -211,11 +213,13 @@ beforeEach(() => { resetStorageCache(); blobs = new OrderRecordingBlobStore(); - restoreStorage = setStorageForTesting({ - profile: { structured: { kind: 'disk' }, blobs: { kind: 'disk' } }, - structured: new DiskStructuredStore(), - blobs, - } satisfies Storage); + restoreStorage = setStorageForTesting( + composeStorage( + { structured: { kind: 'disk' }, blobs: { kind: 'disk' } }, + new DiskStructuredStore(), + blobs, + ), + ); }); afterEach(() => { @@ -309,7 +313,7 @@ describe('deleteSpace composition', () => { controlled.blockPuts = true; installBlobStore(controlled); - const putting = canvasBlobs('canvas-a').put( + const putting = space('canvas-a').blobs.put( 'in-flight.bin', Buffer.from('bytes'), ); @@ -349,7 +353,7 @@ describe('deleteSpace composition', () => { const deleting = deleteSpace('canvas-a'); await controlled.deleteStarted.promise; expect(workspaceState.leaseCount).toBe(1); - const putting = canvasBlobs('canvas-a').put( + const putting = space('canvas-a').blobs.put( 'too-late.bin', Buffer.from('orphan'), ); @@ -462,8 +466,8 @@ describe('deleteSpace composition', () => { controlled.blockPuts = true; installBlobStore(controlled); - const first = canvasBlobs('canvas-a').put('first.bin', Buffer.from('1')); - const second = canvasBlobs('canvas-a').put('second.bin', Buffer.from('2')); + const first = space('canvas-a').blobs.put('first.bin', Buffer.from('1')); + const second = space('canvas-a').blobs.put('second.bin', Buffer.from('2')); await vi.waitFor(() => expect(controlled.putCalls).toBe(2)); controlled.releasePuts(); @@ -484,7 +488,7 @@ describe('deleteSpace composition', () => { await controlled.deleteStarted.promise; await expect( - canvasBlobs('canvas-b').put('independent.bin', Buffer.from('free')), + space('canvas-b').blobs.put('independent.bin', Buffer.from('free')), ).resolves.toMatchObject({ name: 'independent.bin' }); expect(existsSync(artifactPath('canvas-b', 'independent.bin'))).toBe(true); diff --git a/apps/server/src/modules/storage/index.ts b/apps/server/src/modules/storage/index.ts index 01a1395f3..991cc2a7e 100644 --- a/apps/server/src/modules/storage/index.ts +++ b/apps/server/src/modules/storage/index.ts @@ -72,7 +72,7 @@ export type { export { adoptWorkspaceDirectory, - canvasBlobs, + composeStorage, createSpace, createStorage, deleteSpace, @@ -83,12 +83,13 @@ export { hasWorkspaceRegistry, initStorage, setStorageForTesting, - spaceDirectory, + space, storageHealth, workspaceAtDirectory, workspaceDirectory, } from './storage.js'; -export type { SpaceDeleteOutcome, Storage } from './storage.js'; +export type { Space, SpaceDeleteOutcome, Storage } from './storage.js'; +export type { DiskSpaceTree } from './backends/disk/space-tree.js'; export { parseStorageProfile, StorageProfileError, diff --git a/apps/server/src/modules/storage/module-boundaries.test.ts b/apps/server/src/modules/storage/module-boundaries.test.ts index 80fdca34e..628415e20 100644 --- a/apps/server/src/modules/storage/module-boundaries.test.ts +++ b/apps/server/src/modules/storage/module-boundaries.test.ts @@ -282,6 +282,64 @@ describe('workspace module names no backend', () => { }); }); +/** + * The Disk Space directory, fenced by name and by census (proposal §12.6.2). + * + * `diskTree` is not a port and is not portable: a backend that keeps Spaces in + * tables has no directory, and the member is typed by that absence. What keeps + * an unportable capability from reading as a portable one is not where it + * hangs — it is on the Space handle, beside everything else about a Space — + * but its name and the fact that every consumer is written down here. + * + * This list may shrink and must not grow. Each entry is a family §6.4.3 + * assigns a disposition: the **A** families stay and become capability-matrix + * rows, and the rest leave as they move onto a port. + */ +describe('Disk Space tree capability', () => { + const EXPECTED_CONSUMERS = [ + // A — the built-in file tools' sandbox root. + 'modules/agent/tools/handlers/fs-sandbox.ts', + // A — bundle export. + 'modules/canvas/canvas.route.ts', + // A — external-note claim. + 'modules/canvas/external.route.ts', + // C — the resurrection guard, which disappears with the substrate. + 'modules/agent/memory/trigger.ts', + // D — the per-Space RFS access guide, headed for a blob. + 'modules/remote_fs/skill.ts', + // C and D — memory files, the debug prompt log, ACP session state. + 'modules/workspace/paths.ts', + ].sort(); + + it('keeps the exact production consumer census', () => { + const consumers = sourceFiles + .filter((file) => !file.startsWith('modules/storage/')) + .filter((file) => !file.endsWith('.test.ts')) + .filter((file) => /\.diskTree\b/.test(read(file))); + + expect(consumers.sort()).toEqual(EXPECTED_CONSUMERS); + }); + + it('exposes no portable path accessor from the barrel', () => { + const barrel = read('modules/storage/index.ts'); + + // A Space's directory is reachable only through the Disk-named member on + // the Space handle. A free `spaceDirectory()`-shaped export would read as + // something every backend answers, which is the claim being prevented. + expect(barrel).not.toMatch(/\bspaceDirectory\b/); + expect(barrel).toMatch(/\bDiskSpaceTree\b/); + }); + + it('names Disk at the type, so a consumer cannot mistake it for a port', () => { + const tree = read('modules/storage/backends/disk/space-tree.ts'); + + expect(tree).toMatch(/export interface DiskSpaceTree/); + // Living under `backends/disk/` is what the `ports/` census already + // guarantees; this states the intent the file exists to carry. + expect(tree).toMatch(/not a port/i); + }); +}); + describe('structured write authority', () => { it('does not expose compatibility create/delete writers from the public barrel', () => { expect(read('modules/storage/index.ts')).not.toMatch( diff --git a/apps/server/src/modules/storage/paths.ts b/apps/server/src/modules/storage/paths.ts index b11ab904e..50d44647d 100644 --- a/apps/server/src/modules/storage/paths.ts +++ b/apps/server/src/modules/storage/paths.ts @@ -6,8 +6,8 @@ * * Inside the storage module, import from * `storage/backends/disk/layout.js`. Application code should use - * `spaceDirectory()` or the workspace-owned paths when those express the - * capability it needs. This file exists for the remaining explicit Disk + * `space(canvasId).diskTree` or the workspace-owned paths when those express + * the capability it needs. This file exists for the remaining explicit Disk * layout reads while they migrate; it must never contain logic, and no new * call site may import it (enforced by the module-boundary test). */ diff --git a/apps/server/src/modules/storage/ports/workspace.ts b/apps/server/src/modules/storage/ports/workspace.ts index 242e4de2a..d2d0be32e 100644 --- a/apps/server/src/modules/storage/ports/workspace.ts +++ b/apps/server/src/modules/storage/ports/workspace.ts @@ -13,8 +13,9 @@ * 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 + * that have one — the Workspace-level counterpart to the Space handle's + * `diskTree` member (docs/proposals/multi-backend-storage.md §6.4.1). + * Adopting a directory as * a Workspace lives there for the same reason. * * This file may not import a backend implementation or application workspace diff --git a/apps/server/src/modules/storage/storage.ts b/apps/server/src/modules/storage/storage.ts index e42d47a3d..4d5bab65e 100644 --- a/apps/server/src/modules/storage/storage.ts +++ b/apps/server/src/modules/storage/storage.ts @@ -31,7 +31,7 @@ import { getWorkspacePath, } from '../workspace.js'; import { DiskBlobStore } from './backends/disk/blob-store.js'; -import { canvasRoot } from './backends/disk/layout.js'; +import { diskSpaceTree } from './backends/disk/space-tree.js'; import { DiskStructuredStore } from './backends/disk/structured-store.js'; import { DiskWorkspaceRepository, @@ -46,6 +46,7 @@ import { } from './profile.js'; import { withSpacePutAdmission } from './space-lifecycle-admission.js'; +import type { DiskSpaceTree } from './backends/disk/space-tree.js'; import type { BlobInfo, BlobLease, @@ -58,6 +59,7 @@ import type { StorageHealth } from './ports/common.js'; import type { SpaceCreateResult, SpaceDeleteFinishResult, + SpaceHandle, StructuredStore, } from './ports/structured.js'; import type { @@ -110,6 +112,70 @@ export interface Storage { readonly profile: StorageProfile; readonly structured: StructuredStore; readonly blobs: BlobStore; + /** + * Every storage capability for one Space, from one call. + * + * A Space's durable state spans both ports — its record and nodes are + * structured, its files are bytes — so the application reaches all of it + * through one object rather than remembering which axis holds what + * (§6.4.1). + */ + space(canvasId: string): Space; +} + +/** + * One Space across every axis that holds part of it. + * + * A **composition-layer facade, not a port type**. `StructuredStore.space()` + * keeps returning the structured-only {@link SpaceHandle}, `BlobStore.scope()` + * keeps returning a {@link BlobScope}, and neither port imports the other. + * They are joined here because this is the only object in the process that + * holds both, and because this layer already owns every cross-store rule: the + * blob-put precondition and the blob-first delete saga. + * + * The join cannot move down into a port. The two axes are configured + * independently, so a `SpaceHandle` that vended blobs would oblige the Disk + * structured adapter to construct an Azure blob scope; deletion ordering + * deliberately keeps remote blob I/O outside any database transaction; and + * `BlobScopeRef` covers scopes that have no Space at all, which a blob store + * reachable only through a Space handle could not serve. + */ +export interface Space extends SpaceHandle { + /** + * This Space's blobs, with the cross-store precondition applied. + * + * Bytes may only be added to a Space whose record exists. Reads and + * `deleteAll()` stay available for cleanup when a record has already gone. + */ + readonly blobs: BlobScope; + /** + * Disk's directory for this Space. `null` on every other backend. + * + * A capability only some backends implement, named for the backend that has + * it and typed by its absence — not hidden behind a parallel free function, + * and not a stub that throws. A caller branching on `null` is told the truth + * once; a caller that must remember a second import is being asked to know + * this module's internal topology. + */ + readonly diskTree: DiskSpaceTree | null; +} + +function composeSpace(storage: Storage, canvasId: string): Space { + const handle = storage.structured.space(canvasId); + return { + canvasId: handle.canvasId, + read: () => handle.read(), + write: (input) => handle.write(input), + nodes: handle.nodes, + changes: handle.changes, + tasks: handle.tasks, + events: handle.events, + blobs: guardedBlobScope(storage, canvasId), + diskTree: + storage.profile.structured.kind === 'disk' + ? diskSpaceTree(canvasId) + : null, + }; } function buildBlobStore(profile: StorageProfile): BlobStore { @@ -133,14 +199,42 @@ function buildStructuredStore(profile: StorageProfile): StructuredStore { } } +/** + * Assemble a {@link Storage} from connections the caller already holds. + * + * Does not validate the profile and opens nothing — it only wires the Space + * facade over two given stores. Exists so anything holding its own + * connections composes the same facade the process does, rather than a + * partial object literal that would go stale the next time {@link Storage} + * gains a member. + */ +export function composeStorage( + profile: StorageProfile, + structured: StructuredStore, + blobs: BlobStore, +): Storage { + return { + profile, + structured, + blobs, + // Composes from the receiver, not from a captured local. Substituting one + // axis by spreading — `{...storage, blobs: fake}` — is the obvious way to + // stub a backend, and a closure over the original object would hand that + // copy Spaces built on the stores it just replaced, silently. + space(this: Storage, canvasId: string): Space { + return composeSpace(this, canvasId); + }, + }; +} + /** Validate a profile and construct both connections. Does not `init()`. */ export function createStorage(profile: StorageProfile): Storage { validateStorageProfile(profile); - return { + return composeStorage( profile, - structured: buildStructuredStore(profile), - blobs: buildBlobStore(profile), - }; + buildStructuredStore(profile), + buildBlobStore(profile), + ); } // ─── Process-wide holder ──────────────────────────────────────────────────── @@ -180,7 +274,7 @@ export function hasWorkspaceRegistry(): boolean { * The Workspace repository, narrowed to a backend that materializes * Workspaces as real directories. * - * This is the Workspace-level twin of {@link spaceDirectory}: the port + * This is the Workspace-level twin of {@link Space.diskTree}: 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 @@ -352,15 +446,18 @@ export async function deleteSpace( } /** - * Blob scope for one Space — the only scope kind today. + * Blob scope for one Space, with the cross-store precondition applied. * * The raw BlobStore intentionally knows nothing about structured lifecycle, * so composition owns the one cross-store invariant: bytes may only be added * to a Space whose record exists. Reads and `deleteAll()` stay available for * cleanup/recovery when a record has already gone missing. + * + * Takes its {@link Storage} rather than resolving the process-wide holder, so + * one Space facade is composed entirely from the connections it was built + * against — a scope that re-resolved the holder could outlive them. */ -export function canvasBlobs(canvasId: string): BlobScope { - const storage = ensure(); +function guardedBlobScope(storage: Storage, canvasId: string): BlobScope { const workspacePath = activeWorkspacePath(); const delegate = storage.blobs.scope({ kind: 'canvas', canvasId }); @@ -419,22 +516,15 @@ export async function storageHealth(): Promise { } /** - * The real directory backing a Space — the materialization capability. - * - * Some consumers genuinely need a filesystem path rather than a record: an - * ACP agent needs a working directory, the external watcher needs something - * to watch, RFS exposes a tree. That is a product requirement, not a leak - * (proposal §12.5.4), and it is the Space-level counterpart to - * `BlobScope.materialize()`. + * Every storage capability for one Space — the shorthand call sites use. * - * It lives in the composition root because only this module may ask a named - * backend where anything is. Every profile selectable today materializes, so - * this resolves unconditionally; a backend that stores Spaces without a - * directory would refuse here rather than hand back a path that does not - * exist. + * Exactly `getStorage().space(canvasId)`, and an ergonomic spelling of the + * same method rather than a second design. One function answers every storage + * question about a Space: its record, its nodes, its logs, its Tasks, its + * bytes, and — where the backend has one — its directory. */ -export function spaceDirectory(canvasId: string): string { - return canvasRoot(canvasId); +export function space(canvasId: string): Space { + return ensure().space(canvasId); } /** diff --git a/apps/server/src/modules/workspace/legacy-workspace-activation.test.ts b/apps/server/src/modules/workspace/legacy-workspace-activation.test.ts index 756569b33..0842b1708 100644 --- a/apps/server/src/modules/workspace/legacy-workspace-activation.test.ts +++ b/apps/server/src/modules/workspace/legacy-workspace-activation.test.ts @@ -5,7 +5,7 @@ * End-to-end activation of a legacy workspace, over the production routes. * * Phase 4.5 moved the Disk record layout inside the storage boundary and - * routed every "where is this Space" question through `spaceDirectory()`. This + * routed every "where is this Space" question through one accessor. This * suite exists to prove that the move did not change what the app can read or * write. It does not test a module — it activates a workspace the way a launch * does (`setWorkspacePath` → `prepareWorkspaceOnDisk` → every migration) and @@ -57,11 +57,23 @@ import agentRoutes from '../agent/agent.route.js'; import artifactRoute from '../artifact/artifact.route.js'; import canvasRoutes from '../canvas/canvas.route.js'; import { createCanvas } from '../storage/compatibility/canvas.js'; -import { resetStorageCache, spaceDirectory } from '../storage/index.js'; +import { resetStorageCache, space } from '../storage/index.js'; import { setWorkspacePath } from '../workspace.js'; import type { FastifyInstance } from 'fastify'; +/** + * The Space's Disk directory, or a test failure. + * + * These cases are Disk-specific by construction; the assertion states that + * rather than letting an optional-chained `undefined` quietly pass. + */ +function diskDirOf(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('Expected the Disk backend in this test'); + return tree.directory(); +} + /** The Space under test: title-derived directory name ≠ canvasId. */ const CANVAS_ID = 'legacy-space-1'; const SPACE_TITLE = 'Legacy Space'; @@ -203,7 +215,7 @@ async function seedLegacyWorkspace(): Promise { } // A legacy artifact, in the layout the Disk backend has always used. - const spaceDir = spaceDirectory(CANVAS_ID); + const spaceDir = diskDirOf(CANVAS_ID); mkdirSync(join(spaceDir, '.artifacts'), { recursive: true }); writeFileSync( join(spaceDir, '.artifacts', 'art_legacy.txt'), @@ -408,7 +420,7 @@ describe('activating a legacy workspace on the new storage boundary', () => { // 2. Execute a real command batch that imports a staged local file into // the artifact store through the blob port. - const uploadDir = join(spaceDirectory(CANVAS_ID), '.upload'); + const uploadDir = join(diskDirOf(CANVAS_ID), '.upload'); mkdirSync(uploadDir, { recursive: true }); writeFileSync(join(uploadDir, 'fresh.txt'), 'freshly imported bytes'); const exec = await app.inject({ diff --git a/apps/server/src/modules/workspace/paths.ts b/apps/server/src/modules/workspace/paths.ts index 773816e27..de4407ae4 100644 --- a/apps/server/src/modules/workspace/paths.ts +++ b/apps/server/src/modules/workspace/paths.ts @@ -11,7 +11,7 @@ * Untouched by a backend switch. * - Per-Space state owned by *other* domains — memory, ACP sessions, the * debug prompt log — which need a materialized directory but not the Disk - * record layout. They anchor on `spaceDirectory()` from the storage + * record layout. They anchor on the Space's Disk tree from the storage * facade, so they no longer consult the Disk name index (§12.5.4). * * The Disk record and blob layout moved to `storage/backends/disk/layout.ts`. @@ -36,7 +36,7 @@ import path from 'node:path'; import { sanitizeId } from '../../utils/fs.js'; -import { spaceDirectory } from '../storage/index.js'; +import { space } from '../storage/index.js'; import { getWorkspacePath } from '../workspace.js'; import type { Namespace } from '@agenetes/protocol'; @@ -49,8 +49,28 @@ import type { Namespace } from '@agenetes/protocol'; */ const LEGACY_HISTORY_DIR_NAME = '.history'; +/** + * The Space's real directory, or a refusal. + * + * Every path this module builds is for a family Phase 4.6 relocates — memory + * state and the debug prompt log to the extension substrate, the memory body + * to a blob, ACP sessions with phase 6 (proposal §6.4.3). Until then they are + * bare files, so one branch here says once what each of them would otherwise + * repeat: these paths exist only where the backend has a tree. + */ +function spaceRoot(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) { + throw new Error( + `Per-Space files for "${canvasId}" need a Space directory, which the ` + + 'active structured backend does not provide.', + ); + } + return tree.directory(); +} + function legacyHistoryDir(canvasId: string): string { - return path.join(spaceDirectory(canvasId), LEGACY_HISTORY_DIR_NAME); + return path.join(spaceRoot(canvasId), LEGACY_HISTORY_DIR_NAME); } // ─── Memory module paths ─────────────────────────────────────────────────── @@ -71,7 +91,7 @@ export function workspaceMemoryPath(): string { export const WORKING_MEMORY_DIR_NAME = '.memory'; export function canvasMemoryDir(canvasId: string): string { - return path.join(spaceDirectory(canvasId), WORKING_MEMORY_DIR_NAME); + return path.join(spaceRoot(canvasId), WORKING_MEMORY_DIR_NAME); } /** Working memory body for a canvas. */ From 0771529b3a132eba3573969b418c766a0a6eb737 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Mon, 24 Aug 2026 19:05:14 +0800 Subject: [PATCH 3/4] docs(storage): record the one Space handle and split the remaining work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap between the portable contracts and a second structured adapter was one unnamed lump. §6.4 settles the two questions inside it — how the application reaches a Space, and what happens to the per-Space state that is still a file because it always was one — and §§12.6–12.8 build it as three change sets rather than one phase, so each lands and is reviewed on its own. §6.4.1 is the handle; §6.4.2 replaces the single "it is Disk-only" answer with four dispositions and says why A is the default and the cheap one; §6.4.3 assigns every current consumer; §6.4.4 makes the extension point a connection rather than a data API, which is what keeps `StructuredStore` from growing one member per feature. §12.6 records what is implemented: the completed node read surface, `ensureWorld()`, the `space(canvasId)` facade, and `diskTree`. §12.7 and §12.8 state what the two following change sets own, so the boundary between them is written down before either starts rather than after. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011EKZXxEWwZvAZo8YMaLv4m --- docs/architecture/canvas-storage.md | 35 +- docs/proposals/multi-backend-storage.md | 446 +++++++++++++++++++++++- 2 files changed, 459 insertions(+), 22 deletions(-) diff --git a/docs/architecture/canvas-storage.md b/docs/architecture/canvas-storage.md index 911b31852..87e3e5964 100644 --- a/docs/architecture/canvas-storage.md +++ b/docs/architecture/canvas-storage.md @@ -4,7 +4,7 @@ ## 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. +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`. `SpaceNodes` reads one node by id, a named selection through `readMany()`, and the whole Space through `list()` / `stream()`; all four return the same records and the same opaque revisions. The application reaches all of it through one `space(canvasId)` handle on the composition root, which joins the structured handle with the Space's blob scope and, on Disk, its `diskTree`. 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 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. @@ -50,9 +50,12 @@ Key points: - 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. -- `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. +- `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 the Space handle's `diskTree` member 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. +- `space(canvasId)` is the one entry point to a Space. It is a composition-layer facade, not a port type: `StructuredStore` and `BlobStore` never import each other, and they are joined only where the cross-store rules already live — the blob-put precondition and the blob-first delete saga. It composes from its receiver, so substituting one axis on a `Storage` object yields Spaces built on the substitute. +- A capability only one backend has hangs off that same handle, named for the backend and typed by its absence rather than stubbed to throw: `diskTree` is the Disk Space directory and is `null` on every other backend. It is not a port and does not live in `ports/`. `module-boundaries.test.ts` holds its exact production consumer census — a list that may shrink and must not grow — and asserts the barrel exposes nothing that reads as a portable path API. +- `SpaceRepository.ensureWorld()` is the backend-neutral World bootstrap: it returns the established World or mints exactly one version-0 World when the namespace holds none. An _established_ World that is missing or malformed stays the integrity error `worldId()` reports, because regenerating identity there would orphan every reference to it. Disk delegates to the same idempotent primitive Workspace preparation calls, so one file keeps one writer. - 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. @@ -64,7 +67,7 @@ Key points: - Canonical World preview identity is server-owned: non-system commands cannot create, repoint, or delete managed previews. Users may move and resize them. Ordinary Spaces may create and delete their own `spacePreview` nodes through normal UI commands. - Legacy `canvasRef`, `frameRef`, `nodeRef`, `SET_PORTAL_NODE_PINS`, and `GET /api/canvas/:worldCanvasId/references` remain compatibility surfaces for stored World data but are no longer created or exposed by the redesigned World UI. The current model is specified in [space-preview.md](./space-preview.md). - Node filenames are `safe(label).md`; the node's stable id lives in the `id:` frontmatter field. -- The Disk `BlobStore` maps each Space scope to `.artifacts/`, with blobs named `` and no manifest file — the filename is the URL key. Ordinary callers resolve the scope through `canvasBlobs(canvasId)`: `put()` requires an existing Space record, while reads and `deleteAll()` remain available for recovery after a record goes missing. `CanvasStore` owns no artifact methods. Only the Disk blob and structured backends are implemented and selectable today. +- The Disk `BlobStore` maps each Space scope to `.artifacts/`, with blobs named `` and no manifest file — the filename is the URL key. Ordinary callers resolve the scope through `space(canvasId).blobs`: `put()` requires an existing Space record, while reads and `deleteAll()` remain available for recovery after a record goes missing. `CanvasStore` owns no artifact methods. Only the Disk blob and structured backends are implemented and selectable today. - Remote PDF preprocessing writes the already-fetched source bytes into the Space BlobStore as `artifact-.pdf` before structured persistence and replaces the node's remote `src` with that key. As with other artifact imports, this blob write precedes the node write operation; a later structured persistence failure may therefore leave an unreferenced blob until Space deletion, while a blob-write failure degrades to retaining the remote URL. - Events are append-only JSONL (`events.jsonl`); each line is `{ ts: number, payload: RecentAction }`. - The memory analyzer reads Space existence and at most 100 recent action events through one `SpaceHandle`. A missing Space skips the pass before reading memory files or calling the model; corrupt part data still fails the pass. Memory body/state files remain materialized workspace paths, while Agenetes-owned chat history is not part of the curator bundle. @@ -77,19 +80,19 @@ Key points: `apps/server/src/modules/storage/` has three layers plus its composition root: -| 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. | -| `backends/disk/legacy/` | The legacy `CanvasStore` and its synchronous adapter primitives, bounded Workspace-qualified cache, and process-local node tombstones. | -| `compatibility/canvas.ts` | Residual Disk reads plus direct-module create/delete test fixtures; lifecycle writers are not exported from the public storage barrel. | -| `space-lifecycle-admission.ts` | Backend-neutral, writer-preferring single-process coordinator shared by structured mutations and blob puts during a delete session. | -| `profile.ts` and `storage.ts` | Two-axis backend selection, validation, adapter construction, process-wide lifecycle, blob scopes, and the blob-first deletion saga. | -| `index.ts` | Public exports only; application code imports here rather than reaching into an adapter. | -| `canvas-store.ts`, `paths.ts`, `canvas-dirs.ts` | Deprecated forwarding shims with no logic, retained only for high-fanout compatibility imports. | +| 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 — including the `ensureWorld()` bootstrap hook — 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. | +| `backends/disk/legacy/` | The legacy `CanvasStore` and its synchronous adapter primitives, bounded Workspace-qualified cache, and process-local node tombstones. | +| `compatibility/canvas.ts` | Residual Disk reads plus direct-module create/delete test fixtures; lifecycle writers are not exported from the public storage barrel. | +| `space-lifecycle-admission.ts` | Backend-neutral, writer-preferring single-process coordinator shared by structured mutations and blob puts during a delete session. | +| `profile.ts` and `storage.ts` | Two-axis backend selection, validation, adapter construction, process-wide lifecycle, blob scopes, and the blob-first deletion saga. | +| `index.ts` | Public exports only; application code imports here rather than reaching into an adapter. | +| `canvas-store.ts`, `paths.ts`, `canvas-dirs.ts` | Deprecated forwarding shims with no logic, retained only for high-fanout compatibility imports. | 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. diff --git a/docs/proposals/multi-backend-storage.md b/docs/proposals/multi-backend-storage.md index d8b150c3d..23c48130d 100644 --- a/docs/proposals/multi-backend-storage.md +++ b/docs/proposals/multi-backend-storage.md @@ -1,7 +1,7 @@ # Multi-Backend Storage -Status: Phases 1–4 implemented -Last updated: 2026-08-11 +Status: Phases 1–4.5 implemented; §§12.6–12.8 in progress +Last updated: 2026-08-24 > **Scope and decision confidence.** This proposal records the two-port > `StructuredStore` / `BlobStore` split and their target backend families as @@ -49,9 +49,15 @@ Last updated: 2026-08-11 > log-family interface segregation (§12.2.6), and retained-handle Workspace > guards (§12.2.4). Remaining Disk-only read and physical capabilities still > keep non-Disk profiles unselectable. No SQLite, Postgres, or Azure adapter -> exists. §12 is the -> authoritative phase plan; the decision table in §2 marks what each phase -> has actually settled. +> exists. +> +> Phase 4.5 moved storage-owned Disk layout behind the storage boundary in +> PR #93. What remains between the portable contracts and a second structured +> adapter is specified in §6.4 and built by three change sets rather than one +> phase: §12.6 (one Space handle and the portable read surface, +> **implemented**), §12.7 (backend-agnostic application reads), and §12.8 (the +> dispositions and the product-level harness). §12 is the authoritative plan; +> the decision table in §2 marks what each step has actually settled. --- @@ -90,6 +96,8 @@ 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). | +| Reaching one Space | **Accepted** (§12.6) | One `space(canvasId)` facade on the composition root joins both ports; the two ports keep their independence and are joined only where the cross-store rules already live. A capability only one backend has hangs off the same handle, named for that backend and typed by its absence — `diskTree`, `null` elsewhere (§6.4.1). | +| Residual per-Space files | **Settled direction** | Four dispositions, not one: Disk-only and declared, portable and re-implemented, structured record, or blob (§6.4.2). Every current consumer is assigned in §6.4.3; scheduling is what stays open, and nothing is built before a backend needs it. | | 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. | @@ -320,6 +328,281 @@ rejects kinds that are named but not implemented, so an unsupported profile fails at startup with an actionable message rather than nondeterministically while serving data. +### 6.4 One Space handle, four dispositions — revised direction + +Phases 1–4.5 established the two ports and pulled storage-owned layout inside +the boundary. What no phase has settled is the residue: the per-Space state +that is still a file because it always was one, and the fact that reaching a +Space means calling two unrelated functions. This section settles both and +supersedes the single "Space materialization" framing of §12.5.4. §§12.6–12.8 +build what it settles, one change set each. + +Confidence: §6.4.1, the four-outcome test in §6.4.2, the assignments in +§6.4.3, and the opaque-state member in §6.4.4 are a **settled direction**. +What remains open is scheduling — which adapter pays for which move (§12.8, +§12.9) — and the concrete member names, which are still discussion aids. + +#### 6.4.1 One handle per Space + +A Space's durable state spans both ports — its record and nodes are +structured, its files are bytes — so the application should reach all of it +through one object, from one function, on the object that already holds both +ports: + +```ts +interface Storage { + readonly profile: StorageProfile; + readonly structured: StructuredStore; + readonly blobs: BlobStore; + space(canvasId: string): Space; +} +``` + +`Storage` is the composition root's own type — it is what `getStorage()` +already returns, and it is the only object in the process that holds both +ports, so it is where the two are allowed to meet. The barrel exports a free +`space(canvasId)` that is exactly `getStorage().space(canvasId)`, matching how +`canvasBlobs()` used to be called; that is an ergonomic shorthand for the same +method, not a second design. + +**Both ports are reached the same way.** `StructuredStore.space(id)` returns +the structured `SpaceHandle`; `BlobStore.space(id)` returns `SpaceBlobs`, one +member per user-visible area. A Space is the unit the application addresses on +either axis, so a port that made the caller assemble a descriptor first would +be the odd one out — and the asymmetry is visible in the facade, which builds +scope descriptors by hand beside one structured handle. §12.8 closes it. + +`Space` is a **composition-layer facade, not a port type**. The two ports keep +their interfaces and their independence — neither imports the other (§6.3) — +and are joined in the layer that already owns every cross-store rule: the +blob-put precondition ("bytes only for a Space whose record exists") and the +blob-first delete saga. The facade flattens both handles, so every durable part +of a Space sits at one level and which axis stores it stays storage's business. + +The join cannot move down into a port, for two separate reasons: + +- the two axes are configured independently, so a `SpaceHandle` that vended + blobs would oblige the Disk structured adapter to construct an Azure blob + handle; +- deletion ordering deliberately keeps remote blob I/O outside any database + transaction (§6.1); a handle owning both would move that ordering inside an + adapter. + +A blob area with no Space — workspace assets, agent scratch — is a different +entry point on the connection when something needs one, not a reason to make +every caller name a descriptor today. + +What was wrong was therefore only the spelling. +`getStructuredStore().space(id)`, `canvasBlobs(id)`, and `spaceDirectory(id)` +were three entry points that never said they addressed one Space. + +**Backend-specific members hang off the same handle.** A capability only some +backends implement is named for the backend that has it and typed by its +absence (`null`), not hidden behind a parallel free function and not present as +a stub that throws: + +```ts +interface Space { + readonly canvasId: string; + read(): Promise; + write(input: SpaceWriteInput): Promise; + readonly nodes: SpaceNodes; + readonly changes: SpaceChanges; + readonly tasks: SpaceTasks; + readonly events: SpaceEvents; + extension(namespace: string): SpaceSubstrate; // §6.4.4 + readonly blobs: BlobScope; + /** Disk's directory for this Space. `null` on every other backend. */ + readonly diskTree: DiskSpaceTree | null; +} +``` + +A caller branching on `null` is told the truth once; a caller that must +remember a second import is being asked to know the storage module's internal +topology. An earlier unmerged attempt at this work exposed the Disk tree as a +standalone `diskSpaceTree()` free function to keep an unportable surface from +looking portable; the fence that actually does that work is the name and the +enumerated consumer list, and both survive the move onto the handle. + +#### 6.4.2 The disposition test + +§12.5.2 asked of a _symbol_: is it still useful, unchanged, when the structured +backend becomes SQLite? That question sorted `paths.ts`. Asked of a _consumer_ +it sorts the residual filesystem population — and it returns four answers, not +one: + +| Disposition | The question under SQLite | Consequence | +| ------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------- | +| **A. Disk-only** | Meaningless — the feature is _about_ a filesystem | Not implemented off Disk. The feature is **unavailable**, not emulated. | +| **B. Portable, re-implemented** | Survives; only the mechanism dies | A declared capability every backend answers in its own way. | +| **C. Structured record** | It was a record wearing a file's clothes | Moves to `StructuredStore`. | +| **D. Blob** | It is genuinely a named file | Moves to `BlobStore`. | + +An earlier unmerged attempt assigned **A** to all of them and offered one +route out: features stop needing a tree, and an agent reaches a Space over +Huabu's HTTP API. That is right for A and wrong for the other three. B, C, and +D do not need the HTTP API — they need a port, and routing them through an API +instead would leave the same state unportable behind a network hop. + +An outcome of A is an acceptable, stated product limitation, not debt. It +belongs in a capability matrix that `validateStorageProfile()` can consult, +alongside the existing rule that an unimplemented kind fails at startup. + +**A is the default answer, and the cheap one.** B, C, and D each cost a port +change, a contract suite, and a migration; A costs a row in the matrix. A +family earns B, C, or D by a product need that survives being told plainly +"this is not available on that backend" — not by being technically portable. +Where the two are close, take A and say so. A workaround that makes a feature +_nearly_ work on a backend is worse than its absence: it has to be built, +tested, and explained, and it hides the limitation instead of stating it. + +**Nothing here is built before a backend needs it.** Assigning a disposition +fixes the direction; it does not schedule the work. B and the open parts of C +and D land with the adapter that first requires them, so the second backend +pays for its own portability rather than Disk paying in advance for a +requirement nobody has stated. The exception is a move that simplifies Disk on +its own merits — deleting an ad-hoc file format in favour of a record the Space +handle already writes — which is worth doing whenever it comes up. + +#### 6.4.3 Inventory + +Every consumer that reaches a Space as a filesystem tree today, with its +disposition. Every assignment is settled; what is deferred is _when_ each is +built, not _where_ it goes — §12.8 builds the ones that pay for themselves on +Disk alone (§6.4.2, §12.9). + +| Consumer | What it does today | Disposition | +| ---------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /:id/export` — Space bundle | `archiver.glob('**/*', {cwd: spaceDir, dot: true})` | **A**, accepted. A _portable_ export generated from records plus reachable blob references is a separate later design (§11); the current bundle is a Disk projection. | +| `POST /import` — Space bundle | Unzip into a staging dir, rename into place | **A**, accepted. Same pairing as export. | +| `POST /:id/reveal-nodes` | `openInFileManager(nodesDir)` | **A**, accepted. The feature _is_ "show me this in Finder". | +| Built-in `read`/`write`/`glob`/`grep` tools (`fs-sandbox`) | Space directory as the sandbox root | **A**, accepted. Off Disk the first-party agent uses RFS/HTTP, which is what external agents already use (§9). | +| Windows directory-handle coordination | `registerHandleOwner` around `fs.watch` handles | **A**, accepted. Exists so a directory rename can succeed; no directory, no problem. | +| External-note observation and claim | `fs.watch` on `nodes/`, then read + unlink | **A** as a product feature; **B** for the notification underneath it. Nothing is built until a second backend exists — see below. | +| RFS path → node resolution | `nodeIdForPath('nodes/Foo.md')` inverts Disk's filename | **B**, deferred. Every backend can mint `nodes/