diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 13e5a43d7..2e6998a88 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -456,9 +456,8 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv { // Ensure the data directory exists so the server doesn't have to // race-condition on first-use creation. The workspace directory is - // intentionally NOT pre-created: in free mode the user picks it via - // the in-app UI (folder picker / path input), and the web client - // persists the selection across launches via localStorage. + // intentionally NOT pre-created: on first launch the user picks it via the + // in-app UI (folder picker / path input), and we remember it from then on. mkdirSync(dataDir, { recursive: true }); if (IS_DEV && webDistPath && !existsSync(webDistPath)) { @@ -469,9 +468,6 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv { ); } - // Notably absent: HUABU_WORKSPACE. Omitting it puts the server in - // free mode, so the web UI shows its workspace picker on first launch. - // // External-agent (ACP) integration: the server embeds an `agentlet` // daemon supervisor (`DaemonSupervisor`) which fork()s the daemon // entry point itself. In packaged builds the entry resolves to @@ -480,12 +476,27 @@ function buildServerEnv(port: number): NodeJS.ProcessEnv { // in dev it falls back to `external/agentlet/packages/local/dist/index.js`. // No env var injection is needed here \u2014 the resolver in // `daemon-supervisor.ts` covers both layouts. + + // The workspace the user last chose, handed to the server at fork. + // + // A server process serves one workspace for its lifetime, so this is where + // the choice takes effect — the shell owns both `workspace.json` and the + // child process, which makes it the only thing that can apply a new one. + // Deliberately *not* `HUABU_WORKSPACE`: that is the operator's lock, and it + // hides the path, removes the picker, and fails the boot when the folder + // cannot be opened. This is the user's own choice, so it stays free mode — + // the picker remains available and a folder that has gone missing lands the + // user back on it instead of killing the app. Absent on first launch, which + // is what shows the picker then. + const savedWorkspace = readWorkspaceStore().path; + return { ...process.env, SERVER_PORT: String(port), HUABU_BIND_HOST: '127.0.0.1', HUABU_DATA_DIR: dataDir, HUABU_SECRET_BRIDGE: '1', + ...(savedWorkspace ? { HUABU_WORKSPACE_STARTUP: savedWorkspace } : {}), ...(webDistPath ? { WEB_DIST_PATH: webDistPath } : {}), NODE_ENV: IS_DEV ? 'development' : 'production', }; @@ -786,6 +797,19 @@ function registerWorkspaceIpc(): void { return next; }); + /** + * Restart the app so the server comes up on the saved workspace. + * + * The renderer calls this after `workspace:set` when a workspace is already + * active. Relaunching the whole app rather than re-forking the server keeps + * one rule about what a running process is looking at: the window, its + * caches, and the server all start again on the same choice. + */ + ipcMain.handle('workspace:restart', () => { + app.relaunch(); + app.quit(); + }); + ipcMain.handle('workspace:remove-recent', (_event, rawPath: unknown) => { if (typeof rawPath !== 'string') { throw new Error('workspace:remove-recent requires a string path'); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c3efef445..5b5c866fb 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -87,6 +87,14 @@ contextBridge.exposeInMainWorld('electronBridge', { 'workspace:remove-recent', path, ) as Promise, + /** + * Restart the app onto the saved workspace. + * + * A server process serves one workspace for its lifetime, so this is how a + * new choice takes effect. Never resolves — the app is on its way down. + */ + restart: (): Promise => + ipcRenderer.invoke('workspace:restart') as Promise, }, window: { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a2d0fd579..fd1fad5e0 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -56,6 +56,7 @@ import { originGuardPlugin, resolveAllowedHostnames, } from './modules/security/index.js'; +import { closeStorage } from './modules/storage/index.js'; import webRoutes from './modules/web/web.route.js'; import { initWorkspaceFromEnv, @@ -364,6 +365,11 @@ if (bundledAgentTeamsPath) { // after the process is gone. Closing them here lets `app.close()` (driven // by the SIGTERM/SIGINT handlers in server.ts) tear them down gracefully. app.addHook('onClose', async () => resetExternalNoteSessions()); +// Close the mounted storage connections on graceful shutdown. Disk has +// nothing to release, but a connection-holding backend does, and a mount that +// outlives the process that owned it is exactly the kind of leak that only +// shows up under the backend nobody has written yet. +app.addHook('onClose', async () => closeStorage()); // Capture the bound TCP port for L1-owned reachback (RFS): the // canvas-scoped `HUABU_RFS_URL` base is built from this. RFS is // canvas-coupled and therefore a pure L1 concern, so the port lives in diff --git a/apps/server/src/modules/agent/agent-node.service.test.ts b/apps/server/src/modules/agent/agent-node.service.test.ts index 01ea97f79..04dd07003 100644 --- a/apps/server/src/modules/agent/agent-node.service.test.ts +++ b/apps/server/src/modules/agent/agent-node.service.test.ts @@ -62,7 +62,7 @@ function createHarness(options?: { : null, listSelectableProfileIds: () => options?.selectableIds ?? ['profile-a'], }), - readCanvasNodes: () => + readCanvasNodes: async () => options?.nodes === undefined ? [ { id: NOTE_ID, type: 'note' }, diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts index 74b072e71..a7b8ba469 100644 --- a/apps/server/src/modules/agent/agent-node.service.ts +++ b/apps/server/src/modules/agent/agent-node.service.ts @@ -25,7 +25,7 @@ import { import { getLogger } from '../../utils/logger.js'; import { executeCanvasCommandsOnHost } from '../canvas/canvas-command-router.js'; import { buildSpatialBundle } from '../canvas/canvas-spatial.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { ExecuteOnServerOutput } from '../canvas/canvas-executor.js'; @@ -87,7 +87,7 @@ interface StoredNode { interface AgentNodeServiceDependencies { getProfileRegistry: () => AgentProfileRegistryPort | null; - readCanvasNodes: (canvasId: string) => StoredNode[] | null; + readCanvasNodes: (canvasId: string) => Promise; execute: (input: { canvasId: string; commands: readonly CanvasCommand[]; @@ -95,8 +95,10 @@ interface AgentNodeServiceDependencies { }) => Promise; } -function defaultReadCanvasNodes(canvasId: string): StoredNode[] | null { - const canvas = getCanvasStore(canvasId).read(); +async function defaultReadCanvasNodes( + canvasId: string, +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) return null; return canvas.state.nodes as StoredNode[]; } @@ -153,11 +155,11 @@ function resolveAnchor( return nodeId; } -export function resolveAgentNodePosition( +export async function resolveAgentNodePosition( canvasId: string, parentNodeId?: CanvasNodeId, -): Point { - const canvas = getCanvasStore(canvasId).read(); +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) { throw new AgentNodeCreationError( 'canvas_not_found', @@ -201,7 +203,7 @@ export class AgentNodeService { throw error; } - const nodes = this.dependencies.readCanvasNodes(input.canvasId); + const nodes = await this.dependencies.readCanvasNodes(input.canvasId); if (!nodes) { throw new AgentNodeCreationError( 'canvas_not_found', diff --git a/apps/server/src/modules/agent/agent-thread-resolver.test.ts b/apps/server/src/modules/agent/agent-thread-resolver.test.ts index 20f1a9b5b..21c3dbe5e 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.test.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.test.ts @@ -16,8 +16,8 @@ function createResolver( content = '', ) { return new AgentThreadResolver({ - readCanvasNodes: () => nodes, - readNodeContent: () => content, + readCanvasNodes: async () => nodes, + readNodeContent: async () => content, }); } @@ -41,8 +41,8 @@ const FIXED_NODE = { }; describe('AgentThreadResolver', () => { - it('resolves a fixed external Agent Node from Canvas storage', () => { - const target = createResolver( + it('resolves a fixed external Agent Node from Canvas storage', async () => { + const target = await createResolver( [FIXED_NODE], 'Existing prompt', ).resolveFixedAgentNode('canvas-a', 'thread-a'); @@ -65,36 +65,36 @@ describe('AgentThreadResolver', () => { }); }); - it('falls back for selectable or unrelated threads', () => { + it('falls back for selectable or unrelated threads', async () => { const selectable = { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' }, }; - expect( + await expect( createResolver([selectable]).resolveFixedAgentNode( 'canvas-a', 'thread-a', ), - ).toBeNull(); - expect( + ).resolves.toBeNull(); + await expect( createResolver([FIXED_NODE]).resolveFixedAgentNode( 'canvas-a', 'thread-other', ), - ).toBeNull(); + ).resolves.toBeNull(); }); - it('resolves any Question Node as a possible parent', () => { + it('resolves any Question Node as a possible parent', async () => { const selectable = { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' }, }; - expect( + await expect( createResolver([selectable]).resolveAgentNodeId('canvas-a', 'thread-a'), - ).toBe('node-agent'); + ).resolves.toBe('node-agent'); }); - it('resolves a Huabu Agent binding for invocation', () => { + it('resolves a Huabu Agent binding for invocation', async () => { const internal = { ...FIXED_NODE, data: { @@ -103,19 +103,23 @@ describe('AgentThreadResolver', () => { }, }; expect( - createResolver([internal]).resolveFixedAgentNode('canvas-a', 'thread-a') - ?.agentBinding, + ( + await createResolver([internal]).resolveFixedAgentNode( + 'canvas-a', + 'thread-a', + ) + )?.agentBinding, ).toEqual({ kind: 'internal' }); }); - it('rejects duplicate threads and corrupt fixed-node metadata', () => { + it('rejects duplicate threads and corrupt fixed-node metadata', async () => { const duplicateResolver = createResolver([ FIXED_NODE, { ...FIXED_NODE, id: 'node-agent-2' }, ]); - expect(() => + await expect( duplicateResolver.resolveFixedAgentNode('canvas-a', 'thread-a'), - ).toThrowError( + ).rejects.toThrowError( expect.objectContaining>({ code: 'duplicate_thread', }), @@ -124,9 +128,9 @@ describe('AgentThreadResolver', () => { const corruptResolver = createResolver([ { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBinding: null } }, ]); - expect(() => + await expect( corruptResolver.resolveFixedAgentNode('canvas-a', 'thread-a'), - ).toThrowError( + ).rejects.toThrowError( expect.objectContaining>({ code: 'invalid_binding', }), diff --git a/apps/server/src/modules/agent/agent-thread-resolver.ts b/apps/server/src/modules/agent/agent-thread-resolver.ts index b8afef64e..f1a44ecb7 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.ts @@ -14,7 +14,7 @@ import { InvalidAgentLaunchOverridesError, parseAgentLaunchOverrides, } from './agent-launch-overrides.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; interface StoredNode { id: string; @@ -23,8 +23,8 @@ interface StoredNode { } interface ResolverDependencies { - readCanvasNodes: (canvasId: string) => StoredNode[] | null; - readNodeContent: (canvasId: string, nodeId: string) => string | null; + readCanvasNodes: (canvasId: string) => Promise; + readNodeContent: (canvasId: string, nodeId: string) => Promise; } export interface FixedAgentNodeTarget { @@ -55,12 +55,12 @@ export class AgentThreadResolutionError extends Error { } const DEFAULT_DEPENDENCIES: ResolverDependencies = { - readCanvasNodes: (canvasId) => { - const canvas = getCanvasStore(canvasId).read(); + readCanvasNodes: async (canvasId) => { + const canvas = await space(canvasId).read(); return canvas ? (canvas.state.nodes as StoredNode[]) : null; }, - readNodeContent: (canvasId, nodeId) => - getCanvasStore(canvasId).readNode(nodeId)?.content ?? null, + readNodeContent: async (canvasId, nodeId) => + (await space(canvasId).nodes.read(nodeId))?.record.content ?? null, }; /** @@ -74,8 +74,11 @@ export class AgentThreadResolver { private readonly dependencies: ResolverDependencies = DEFAULT_DEPENDENCIES, ) {} - resolveAgentNodeId(canvasId: string, threadId: string): CanvasNodeId | null { - const nodes = this.dependencies.readCanvasNodes(canvasId); + async resolveAgentNodeId( + canvasId: string, + threadId: string, + ): Promise { + const nodes = await this.dependencies.readCanvasNodes(canvasId); if (!nodes) { throw new AgentThreadResolutionError( 'canvas_not_found', @@ -93,11 +96,11 @@ export class AgentThreadResolver { return node?.type === 'question' ? (node.id as CanvasNodeId) : null; } - resolveFixedAgentNode( + async resolveFixedAgentNode( canvasId: string, threadId: string, - ): FixedAgentNodeTarget | null { - const nodes = this.dependencies.readCanvasNodes(canvasId); + ): Promise { + const nodes = await this.dependencies.readCanvasNodes(canvasId); if (!nodes) { throw new AgentThreadResolutionError( 'canvas_not_found', @@ -145,7 +148,7 @@ export class AgentThreadResolver { throw error; } - const content = this.dependencies.readNodeContent(canvasId, node.id); + const content = await this.dependencies.readNodeContent(canvasId, node.id); if (content === null) { throw new AgentThreadResolutionError( 'missing_node_content', diff --git a/apps/server/src/modules/agent/agent-thread.service.test.ts b/apps/server/src/modules/agent/agent-thread.service.test.ts index d1bfcc7c9..3d5232558 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -106,7 +106,7 @@ function createHarness(options?: { return emptyInternalStream(); }); const service = new AgentThreadService({ - resolveFixedAgentNode: () => + resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, resolvePersistedExternalBinding: () => options && 'persistedBinding' in options @@ -164,7 +164,7 @@ describe('AgentThreadService', () => { expect(externalBindingFromWorkloadSpec({ binding: {} })).toBeNull(); }); - it('resolves a persisted external Thread without a fixed Agent Node', () => { + it('resolves a persisted external Thread without a fixed Agent Node', async () => { const binding = { kind: 'external' as const, profileId: 'profile-selectable', @@ -172,12 +172,12 @@ describe('AgentThreadService', () => { }; const harness = createHarness({ target: null, persistedBinding: binding }); - expect( + await expect( harness.service.resolveExternalTarget('canvas-a', 'thread-a'), - ).toEqual({ binding, fixedTarget: null }); + ).resolves.toEqual({ binding, fixedTarget: null }); }); - it('prefers the fixed Agent Node binding when one exists', () => { + it('prefers the fixed Agent Node binding when one exists', async () => { const harness = createHarness({ persistedBinding: { kind: 'external', @@ -186,9 +186,9 @@ describe('AgentThreadService', () => { }, }); - expect( + await expect( harness.service.resolveExternalTarget('canvas-a', 'thread-a'), - ).toEqual({ binding: TARGET.agentBinding, fixedTarget: TARGET }); + ).resolves.toEqual({ binding: TARGET.agentBinding, fixedTarget: TARGET }); }); it('uses persisted fixed binding and overrides under one leased lifecycle', async () => { @@ -356,7 +356,7 @@ describe('AgentThreadService', () => { return events([{ type: 'done', data: { message: 'Done' } }]); }); const service = new AgentThreadService({ - resolveFixedAgentNode: () => TARGET, + resolveFixedAgentNode: async () => TARGET, resolvePersistedExternalBinding: () => null, waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => vi.fn()), diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 1ab52dab2..64075e3de 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -34,7 +34,7 @@ interface AgentThreadServiceDependencies { resolveFixedAgentNode: ( canvasId: string, threadId: string, - ) => FixedAgentNodeTarget | null; + ) => Promise; resolvePersistedExternalBinding: ( canvasId: string, threadId: string, @@ -160,20 +160,20 @@ export class AgentThreadService { private readonly dependencies: AgentThreadServiceDependencies = DEFAULT_DEPENDENCIES, ) {} - resolveFixedTarget( + async resolveFixedTarget( canvasId: string | undefined, threadId: string, - ): FixedAgentNodeTarget | null { + ): Promise { return canvasId ? this.dependencies.resolveFixedAgentNode(canvasId, threadId) : null; } - resolveExternalTarget( + async resolveExternalTarget( canvasId: string, threadId: string, - ): ExternalAgentThreadTarget | null { - const fixedTarget = this.resolveFixedTarget(canvasId, threadId); + ): Promise { + const fixedTarget = await this.resolveFixedTarget(canvasId, threadId); if (fixedTarget) { return fixedTarget.agentBinding.kind === 'external' ? { binding: fixedTarget.agentBinding, fixedTarget } @@ -197,7 +197,7 @@ export class AgentThreadService { ): Promise { const fixedTarget = options.fixedTarget === undefined - ? this.resolveFixedTarget(options.canvasId, options.threadId) + ? await this.resolveFixedTarget(options.canvasId, options.threadId) : options.fixedTarget; const binding: AgentBinding = fixedTarget?.agentBinding ?? options.requestBinding ?? { kind: 'internal' }; diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts index 597a2fb53..1ce5a34e2 100644 --- a/apps/server/src/modules/agent/agent.route.ts +++ b/apps/server/src/modules/agent/agent.route.ts @@ -546,7 +546,7 @@ const agentRoutes: FastifyPluginAsync = async ( } = parsed.data; const resolvedThreadId = getOrCreateThreadId(threadId); - const fixedTarget = agentThreadService.resolveFixedTarget( + const fixedTarget = await agentThreadService.resolveFixedTarget( canvasId, resolvedThreadId, ); diff --git a/apps/server/src/modules/agent/conversation/envelope.ts b/apps/server/src/modules/agent/conversation/envelope.ts index f4250ec7b..be7242aeb 100644 --- a/apps/server/src/modules/agent/conversation/envelope.ts +++ b/apps/server/src/modules/agent/conversation/envelope.ts @@ -24,10 +24,11 @@ import { getSkill } from '../../../prompt/index.js'; import { getNodeNeighbourhood } from '../../canvas/node-neighbourhood.js'; import { describeNode } from '../../canvas/node-prompt.js'; import { snapshotNodesToArtifacts } from '../../canvas/snapshot-nodes.js'; -import { getCanvasStore } from '../../storage/index.js'; +import { space } from '../../storage/index.js'; import { isUserInvokableSkill } from '../skills.route.js'; import type { NodeNeighbourhoodContext } from '../../canvas/node-neighbourhood.js'; +import type { NodeSnapshot } from '../../storage/index.js'; import type { AgentNodePreview } from '../node-ref.js'; import type { ChatAttachment, @@ -200,21 +201,26 @@ function collectSketchStrokeSubsets( * The `preview` is picked server-side via the shared * {@link extractAgentNodePreview} ladder (`summary > content[:120] > src`) * — the SAME policy the node-neighbourhood uses — by reading each node's - * on-disk sidecar from the canvas store. Full content is still one tool - * call away; the preview is only a scan hint. When `canvasId` is null the - * store is unavailable, so refs fall back to bare `{ id, type, label?, - * filename }` (no preview). + * stored record. Full content is still one tool call away; the preview is + * only a scan hint. When `canvasId` is null there is no Space to read, so + * refs fall back to bare `{ id, type, label?, filename }` (no preview). + * + * A selection is a named subset, so it is read as one — `readMany` over the + * ids the wire already named, rather than a scan of the Space or a read per + * node (§12.6.1). */ -function collectSelectedNodeRefs( +async function collectSelectedNodeRefs( nodes: WireSelectionNode[], canvasId: string | null, -): AgentNodePreview[] { - let store: ReturnType | null = null; +): Promise { + let records = new Map(); if (canvasId) { try { - store = getCanvasStore(canvasId); + records = await space(canvasId).nodes.readMany( + collectSelectedNodeIds(nodes), + ); } catch { - store = null; + /* Space unreadable — refs stay bare. */ } } const refs: AgentNodePreview[] = []; @@ -226,7 +232,6 @@ function collectSelectedNodeRefs( // label + file + preview + rev for every server-side node context. refs.push( describeNode( - store, { id: n.id, type: n.type, @@ -234,6 +239,7 @@ function collectSelectedNodeRefs( ...(n.src !== undefined ? { src: n.src } : {}), }, 'preview', + records.get(n.id)?.record ?? null, ), ); if (n.children) walk(n.children); @@ -408,13 +414,10 @@ export async function buildChatEnvelope( let anchor: ChatEnvelope['focus']['anchor']; if (anchorNodeId && canvasId) { const neighbourhood = - getNodeNeighbourhood(canvasId, anchorNodeId) ?? undefined; + (await getNodeNeighbourhood(canvasId, anchorNodeId)) ?? undefined; let label: string | undefined; try { - const meta = getCanvasStore(canvasId).readNode(anchorNodeId) as Record< - string, - unknown - > | null; + const meta = (await space(canvasId).nodes.read(anchorNodeId))?.record; if (typeof meta?.label === 'string') label = meta.label; } catch { /* store unavailable — anchor still useful by id */ @@ -427,6 +430,9 @@ export async function buildChatEnvelope( } // Focus: selection refs + derived snapshot artifacts. + const selectionRefs = selectedNodes + ? await collectSelectedNodeRefs(selectedNodes, canvasId) + : []; const selectionImageAttachments = selectedNodes ? collectImageAttachments(selectedNodes) : []; @@ -462,9 +468,7 @@ export async function buildChatEnvelope( }, focus: { selection: { - refs: selectedNodes - ? collectSelectedNodeRefs(selectedNodes, canvasId) - : [], + refs: selectionRefs, selectedIds: selectedNodes ? collectSelectedNodeIds(selectedNodes) : [], imageAttachments: dedupedImageAttachments, snapshotAttachments, diff --git a/apps/server/src/modules/agent/conversation/prompt/attachments.ts b/apps/server/src/modules/agent/conversation/prompt/attachments.ts index b0bb8ca75..dc896e8d7 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'; @@ -223,7 +223,7 @@ export async function buildAttachmentParts( if (resolvedCanvasId && resolvedFilename) { try { const bytes = - await canvasBlobs(resolvedCanvasId).read(resolvedFilename); + await space(resolvedCanvasId).artifacts.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/conversation/prompt/debug-prompt.ts b/apps/server/src/modules/agent/conversation/prompt/debug-prompt.ts index d5f703834..2eaf8a697 100644 --- a/apps/server/src/modules/agent/conversation/prompt/debug-prompt.ts +++ b/apps/server/src/modules/agent/conversation/prompt/debug-prompt.ts @@ -17,9 +17,10 @@ import { appendFileSync } from 'node:fs'; import path from 'node:path'; -import { mkdirp } from '../../../../utils/fs.js'; -import { chatPromptLogPath } from '../../../workspace/paths.js'; +import { sanitizeId } from '../../../../utils/fs.js'; +import { space } from '../../../storage/index.js'; +import type { SpaceSubstrate } from '../../../storage/index.js'; import type { Context } from '@earendil-works/pi-ai'; import type { FastifyBaseLogger } from 'fastify'; @@ -133,10 +134,31 @@ export interface DumpPromptParams { logger: FastifyBaseLogger; } +/** This module's namespace on the storage extension substrate. */ +const PROMPT_LOG_NAMESPACE = 'huabu.prompt.log'; + +/** + * Appends chained so turns land in the log in the order they were dumped. + * + * Resolving a substrate is asynchronous while the call site is a synchronous + * driver callback, so the write cannot happen inline any more. Each turn's + * text is still rendered synchronously — a snapshot of the messages as they + * were — and only the append is deferred. + */ +let appendTail: Promise = Promise.resolve(); + +/** Where this module keeps one log per thread on a Disk substrate. */ +function diskLogPath(substrate: SpaceSubstrate, threadId: string): string { + return path.join( + substrate.directory, + `${sanitizeId(threadId, 'threadId')}.prompt.log`, + ); +} + /** * Append a readable dump of the assembled prompt for one turn. No-op - * unless `HUABU_DEBUG_PROMPT` is set or `canvasId` is missing (the log - * lives under the canvas chat dir). Never throws. + * unless `HUABU_DEBUG_PROMPT` is set, or `canvasId` is missing (the log is + * per-Space state). Never throws, and never blocks the turn. */ export function dumpAssembledPrompt(params: DumpPromptParams): void { if (!isPromptDebugEnabled()) return; @@ -167,13 +189,25 @@ export function dumpAssembledPrompt(params: DumpPromptParams): void { }); out.push('', ''); - const logPath = chatPromptLogPath(canvasId, params.threadId); - mkdirp(path.dirname(logPath)); - appendFileSync(logPath, out.join('\n'), 'utf-8'); + const block = out.join('\n'); + appendTail = appendTail + .then(async () => { + // A Space deleted mid-turn has no substrate, and this is the last + // thing that should recreate one for a debug file. + const substrate = await space(canvasId).extension(PROMPT_LOG_NAMESPACE); + if (!substrate) return; + appendFileSync(diskLogPath(substrate, params.threadId), block, 'utf-8'); + }) + .catch((err: unknown) => { + params.logger.warn( + { err: String(err) }, + 'dumpAssembledPrompt: failed to write prompt debug log', + ); + }); } catch (err) { params.logger.warn( { err: String(err) }, - 'dumpAssembledPrompt: failed to write prompt debug log', + 'dumpAssembledPrompt: failed to render prompt debug log', ); } } diff --git a/apps/server/src/modules/agent/memory/analyzer.test.ts b/apps/server/src/modules/agent/memory/analyzer.test.ts index caa15d40e..6aaa95b7a 100644 --- a/apps/server/src/modules/agent/memory/analyzer.test.ts +++ b/apps/server/src/modules/agent/memory/analyzer.test.ts @@ -7,17 +7,32 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const physicalState = vi.hoisted(() => ({ root: '' })); +const physicalState = vi.hoisted(() => ({ + root: '', + canvasMemory: null as string | null, +})); vi.mock('../agent.service.js', () => ({ runAgent: vi.fn() })); vi.mock('../../../prompt/index.js', () => ({ loadAgent: vi.fn(), listSkills: vi.fn(), })); -vi.mock('../../storage/index.js', () => ({ getStructuredStore: vi.fn() })); +vi.mock('../../storage/index.js', () => ({ + getStructuredStore: vi.fn(), + // The memory body is a blob under the Space's own scope now, so the + // snapshot reads it through here rather than off a path. + space: (canvasId: string) => ({ + memory: { + read: async () => + physicalState.canvasMemory === null + ? null + : Buffer.from(physicalState.canvasMemory, 'utf8'), + canvasId, + }, + }), + SPACE_MEMORY_BLOB_NAME: 'space.md', +})); vi.mock('../../workspace/paths.js', () => ({ - canvasMemoryPath: (canvasId: string) => - `${physicalState.root}/${canvasId}/.memory/space.md`, workspaceMemoryPath: () => `${physicalState.root}/setting/user.md`, })); diff --git a/apps/server/src/modules/agent/memory/analyzer.ts b/apps/server/src/modules/agent/memory/analyzer.ts index fb21fc66f..e5da5d82f 100644 --- a/apps/server/src/modules/agent/memory/analyzer.ts +++ b/apps/server/src/modules/agent/memory/analyzer.ts @@ -33,11 +33,9 @@ import { type CanvasFile, type SpaceHandle, } from '../../storage/index.js'; -import { - canvasMemoryPath, - workspaceMemoryPath, -} from '../../workspace/paths.js'; +import { workspaceMemoryPath } from '../../workspace/paths.js'; import { runAgent } from '../agent.service.js'; +import { readCanvasMemory } from './read.js'; import type { MemoryLogger } from './index.js'; import type { WriteResult } from './writers.js'; @@ -197,7 +195,7 @@ async function assembleContext( parts.push(`${events.count} ops`); } - const memorySnapshot = readMemorySnapshot(canvasId); + const memorySnapshot = await readMemorySnapshot(canvasId); messages.push({ role: 'user', content: `[SYSTEM Current memory]\n${memorySnapshot}`, @@ -279,14 +277,14 @@ function readEventsDigest(events: readonly CanvasEvent[]): EventsDigest | null { return { text: summaries.join('\n'), count: events.length }; } -function readMemorySnapshot(canvasId: string): string { +async function readMemorySnapshot(canvasId: string): Promise { const parts: string[] = []; const longTerm = readFileSafe(workspaceMemoryPath()); parts.push('## Long-term memory'); parts.push(longTerm.trim().length > 0 ? longTerm.trim() : '(empty)'); - const canvas = readFileSafe(canvasMemoryPath(canvasId)); + const canvas = (await readCanvasMemory(canvasId)) ?? ''; parts.push(''); parts.push('## Canvas memory'); parts.push(canvas.trim().length > 0 ? canvas.trim() : '(empty)'); diff --git a/apps/server/src/modules/agent/memory/read.ts b/apps/server/src/modules/agent/memory/read.ts index c8776b05f..a9f6fa8df 100644 --- a/apps/server/src/modules/agent/memory/read.ts +++ b/apps/server/src/modules/agent/memory/read.ts @@ -15,10 +15,8 @@ import { existsSync, readFileSync } from 'node:fs'; -import { - workspaceMemoryPath, - canvasMemoryPath, -} from '../../workspace/paths.js'; +import { space, SPACE_MEMORY_BLOB_NAME } from '../../storage/index.js'; +import { workspaceMemoryPath } from '../../workspace/paths.js'; /** * Read the user memory body. @@ -34,12 +32,19 @@ export function readWorkspaceMemory(): string | null { } /** - * Read the per-canvas canvas memory body. + * Read the per-Space memory body. * - * Same null-on-empty contract as {@link readWorkspaceMemory}. + * Same null-on-empty contract as {@link readWorkspaceMemory}. A blob under the + * Space's own memory scope (proposal §6.4.3, disposition D) — unlike the + * Workspace memory above, which is not scoped to a Space and stays a file. */ -export function readCanvasMemory(canvasId: string): string | null { - return readNonEmpty(canvasMemoryPath(canvasId)); +export async function readCanvasMemory( + canvasId: string, +): Promise { + const bytes = await space(canvasId).memory.read(SPACE_MEMORY_BLOB_NAME); + if (bytes === null) return null; + const raw = bytes.toString('utf8'); + return raw.trim().length === 0 ? null : raw; } function readNonEmpty(file: string): string | null { diff --git a/apps/server/src/modules/agent/memory/sandbox.ts b/apps/server/src/modules/agent/memory/sandbox.ts index 0085b4e43..24d34ae19 100644 --- a/apps/server/src/modules/agent/memory/sandbox.ts +++ b/apps/server/src/modules/agent/memory/sandbox.ts @@ -13,7 +13,7 @@ * * Everything else is rejected. Each writer goes through * {@link resolveLongTermPath} / {@link resolveUserSkillPath} / - * {@link resolveWorkingMemoryPath} so the security model lives in one + * one resolver per tier so the security model lives in one * place — same posture as the chat sandbox at * `modules/agent/tools/handlers/fs-sandbox.ts`. * @@ -29,8 +29,6 @@ import { workspaceMemoryPath, settingDir, userSkillsDir, - canvasMemoryDir, - canvasMemoryPath, } from '../../workspace/paths.js'; /** Thrown by every resolver below on out-of-sandbox attempts. */ @@ -97,16 +95,3 @@ export function resolveUserSkillPath(id: string): string { ensureUnderRoot(root, target, 'user skills'); return target; } - -/** - * Resolve the absolute Space memory file path. Throws if the resolved path - * escapes the canvas's `.memory/` root (a defensive check — the path - * computation in `workspace/paths.ts` already constrains the result, - * but going through `ensureUnderRoot` keeps the invariant explicit). - */ -export function resolveWorkingMemoryPath(canvasId: string): string { - const root = canvasMemoryDir(canvasId); - const target = canvasMemoryPath(canvasId); - ensureUnderRoot(root, target, 'canvas memory'); - return target; -} diff --git a/apps/server/src/modules/agent/memory/trigger.test.ts b/apps/server/src/modules/agent/memory/trigger.test.ts new file mode 100644 index 000000000..f661111ee --- /dev/null +++ b/apps/server/src/modules/agent/memory/trigger.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Memory-worker bookkeeping over the storage extension substrate. + * + * This is the first owner to build a store on a substrate, so it doubles as + * evidence that the substrate is usable without the port growing a data API: + * everything here is this module's own format, in a place storage handed it + * and never reads (proposal §6.4.4). + */ + +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + bumpOpCounter, + markAnalyzed, + OP_THRESHOLD, + readMemoryState, +} from './trigger.js'; +import { createSpace, deleteSpace, space } from '../../storage/index.js'; +import { setWorkspacePath } from '../../workspace.js'; + +const CANVAS = 'canvas-memory-trigger'; +let tmp: string; + +beforeEach(async () => { + tmp = mkdtempSync(path.join(tmpdir(), 'huabu-memory-trigger-')); + setWorkspacePath(tmp); + const created = await createSpace(CANVAS, 'Trigger'); + if (!created.ok) throw new Error('Expected to create the Space'); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('memory trigger state', () => { + it('starts from zero and persists what it counts', async () => { + await expect(readMemoryState(CANVAS)).resolves.toEqual({ + counter: 0, + lastAnalyzedAt: null, + lastSeenThreadCursor: null, + }); + + await expect(bumpOpCounter(CANVAS, 3)).resolves.toBe(false); + await expect(bumpOpCounter(CANVAS, 4)).resolves.toBe(false); + + await expect(readMemoryState(CANVAS)).resolves.toMatchObject({ + counter: 7, + }); + }); + + it('signals once at the threshold and resets in the same write', async () => { + await expect(bumpOpCounter(CANVAS, OP_THRESHOLD)).resolves.toBe(true); + + // Reset in the same write, so the very next op batch cannot double-fire. + await expect(readMemoryState(CANVAS)).resolves.toMatchObject({ + counter: 0, + }); + await expect(bumpOpCounter(CANVAS, 1)).resolves.toBe(false); + }); + + it('keeps concurrent bumps from losing increments', async () => { + await Promise.all( + Array.from({ length: 10 }, () => bumpOpCounter(CANVAS, 1)), + ); + + await expect(readMemoryState(CANVAS)).resolves.toMatchObject({ + counter: 10, + }); + }); + + it('records an analysis pass without touching the counter', async () => { + await bumpOpCounter(CANVAS, 5); + await markAnalyzed(CANVAS, { lastSeenThreadCursor: 42 }); + + const state = await readMemoryState(CANVAS); + expect(state.counter).toBe(5); + expect(state.lastSeenThreadCursor).toBe(42); + expect(state.lastAnalyzedAt).toEqual(expect.any(Number)); + }); + + it('keeps each Space to its own bookkeeping', async () => { + const other = 'canvas-memory-trigger-other'; + const created = await createSpace(other, 'Other'); + if (!created.ok) throw new Error('Expected to create the second Space'); + + await bumpOpCounter(CANVAS, 4); + await bumpOpCounter(other, 9); + + await expect(readMemoryState(CANVAS)).resolves.toMatchObject({ + counter: 4, + }); + await expect(readMemoryState(other)).resolves.toMatchObject({ counter: 9 }); + }); + + it('drops a write for a Space that was deleted mid-flight', async () => { + await bumpOpCounter(CANVAS, 1); + const spaceDir = space(CANVAS).diskTree?.directory(); + if (!spaceDir) throw new Error('Expected the Disk backend in this test'); + + await deleteSpace(CANVAS); + + // The op-counter hook fires after the delete has already removed the + // Space. This used to need a guard here — a bare write would recreate the + // directory as a stub holding nothing but bookkeeping. The port refuses a + // substrate for a Space that is gone, so the write is a silent no-op and + // nothing is resurrected. + await expect(bumpOpCounter(CANVAS, 1)).resolves.toBe(false); + expect(existsSync(spaceDir)).toBe(false); + }); +}); diff --git a/apps/server/src/modules/agent/memory/trigger.ts b/apps/server/src/modules/agent/memory/trigger.ts index 6367f1c58..f2d4b1ced 100644 --- a/apps/server/src/modules/agent/memory/trigger.ts +++ b/apps/server/src/modules/agent/memory/trigger.ts @@ -15,19 +15,38 @@ * legacy chat digest. Existing state files preserve it, * but current analysis passes do not advance it. * - * Persisted at `/.memory/state.json` so the counter - * survives process restarts. The file is kept tiny (<128 B) and - * atomic-written; read / write failures are reported but never thrown - * — losing this state at worst means we miss or duplicate one - * analysis pass, which is harmless. + * Persisted on the storage extension substrate under the `huabu.memory` + * namespace, so the counter survives process restarts. Storage hands this + * module a place and nothing else — it never sees these three fields, and this + * module owns the format, one small store per backend kind (proposal §6.4.4). + * The state is kept tiny (<128 B) and atomic-written; read / write failures are + * reported but never thrown — losing it at worst means we miss or duplicate + * one analysis pass, which is harmless. */ -import { existsSync } from 'node:fs'; +import path from 'node:path'; -import { atomicWriteJson, mkdirp, readJson } from '../../../utils/fs.js'; +import { atomicWriteJson, readJson } from '../../../utils/fs.js'; import { createKeyedMutex } from '../../../utils/keyed-mutex.js'; -import { spaceDirectory } from '../../storage/index.js'; -import { memoryStatePath, canvasMemoryDir } from '../../workspace/paths.js'; +import { space } from '../../storage/index.js'; + +import type { SpaceSubstrate } from '../../storage/index.js'; + +/** This module's namespace on the substrate. */ +const MEMORY_NAMESPACE = 'huabu.memory'; + +/** + * Where this module keeps its state on a Disk substrate. + * + * The whole store, because the state is one whole-value rewrite. A key/value + * member on the port would have covered exactly this and nothing else, for + * every owner forever — which is why the port hands over a place instead + * (§6.4.4). An owner that later wants the same shape extracts a helper *over* + * the substrate, never a port member. + */ +function diskStatePath(substrate: SpaceSubstrate): string { + return path.join(substrate.directory, 'state.json'); +} /** Op-count threshold that triggers a memory analysis pass. */ export const OP_THRESHOLD = 50; @@ -59,9 +78,10 @@ const EMPTY_STATE: MemoryState = { * we'd rather miscount a few ops than crash the request pipeline on * a corrupted bookkeeping file. */ -export function readMemoryState(canvasId: string): MemoryState { - if (!existsSync(memoryStatePath(canvasId))) return { ...EMPTY_STATE }; - const raw = readJson>(memoryStatePath(canvasId)); +export async function readMemoryState(canvasId: string): Promise { + const substrate = await space(canvasId).extension(MEMORY_NAMESPACE); + if (!substrate) return { ...EMPTY_STATE }; + const raw = readJson>(diskStatePath(substrate)); if (!raw || typeof raw !== 'object') return { ...EMPTY_STATE }; return { counter: typeof raw.counter === 'number' ? raw.counter : 0, @@ -74,18 +94,24 @@ export function readMemoryState(canvasId: string): MemoryState { }; } -/** Atomic write of the memory state, creating `.memory/` on demand. */ -export function writeMemoryState(canvasId: string, state: MemoryState): void { - // Resurrection guard: the op-counter `onResponse` hook fires - // *after* DELETE /api/canvas/:id has rm -rf'd the canvas dir, and - // would otherwise mkdirp `.memory/` + drop a fresh `state.json` - // here \u2014 leaving behind a stub canvas dir containing only that - // 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; - mkdirp(canvasMemoryDir(canvasId)); - atomicWriteJson(memoryStatePath(canvasId), state); +/** + * Atomic write of the memory state. + * + * No resurrection guard of its own. The op-counter `onResponse` hook fires + * *after* DELETE /api/canvas/:id has removed the Space, and this module used + * to check the Space directory itself before writing — otherwise it would drop + * a fresh `state.json` into a directory the delete had just removed, leaving a + * stub Space behind. `extension()` refuses a substrate for a Space that is + * gone, so the guard now lives in the one place that can state it for every + * owner rather than being re-derived by each (proposal §12.6.3). + */ +export async function writeMemoryState( + canvasId: string, + state: MemoryState, +): Promise { + const substrate = await space(canvasId).extension(MEMORY_NAMESPACE); + if (!substrate) return; + atomicWriteJson(diskStatePath(substrate), state); } /** @@ -107,16 +133,16 @@ export async function bumpOpCounter( delta: number, ): Promise { if (!Number.isFinite(delta) || delta <= 0) return false; - return stateLock(canvasId, () => { - const state = readMemoryState(canvasId); + return stateLock(canvasId, async () => { + const state = await readMemoryState(canvasId); state.counter += delta; if (state.counter < OP_THRESHOLD) { - writeMemoryState(canvasId, state); + await writeMemoryState(canvasId, state); return false; } // Threshold crossed: reset and signal. state.counter = 0; - writeMemoryState(canvasId, state); + await writeMemoryState(canvasId, state); return true; }); } @@ -139,12 +165,12 @@ export async function markAnalyzed( lastSeenThreadCursor?: number; } = {}, ): Promise { - await stateLock(canvasId, () => { - const state = readMemoryState(canvasId); + await stateLock(canvasId, async () => { + const state = await readMemoryState(canvasId); state.lastAnalyzedAt = Date.now(); if (opts.lastSeenThreadCursor !== undefined) { state.lastSeenThreadCursor = opts.lastSeenThreadCursor; } - writeMemoryState(canvasId, state); + await writeMemoryState(canvasId, state); }); } diff --git a/apps/server/src/modules/agent/memory/writers.ts b/apps/server/src/modules/agent/memory/writers.ts index 9462eb871..fa50c83f9 100644 --- a/apps/server/src/modules/agent/memory/writers.ts +++ b/apps/server/src/modules/agent/memory/writers.ts @@ -12,10 +12,14 @@ * unique substring (Claude * Code style edit). * - * Both take an already-sandbox-resolved absolute path; callers - * (currently `tools/handlers/fs-write.ts`) own the path → tier - * mapping. The `tier` knob here is purely for behaviour that varies - * by destination: + * Both take a {@link MemoryDocument} — where the bytes live, resolved by the + * caller (currently `tools/handlers/fs-write.ts`), which owns the tier + * mapping. The indirection exists because the tiers no longer share a + * substrate: the Workspace-scoped ones are files, while a Space's memory body + * is a blob under its own scope (proposal §6.4.3, disposition D). Everything + * below is rules about the *content*, so none of it should know which. + * + * The `tier` knob is purely for behaviour that varies by destination: * * - cap enforcement (workspace + canvas only — skill bodies are * allowed to grow larger) @@ -38,6 +42,58 @@ import { atomicWriteText, mkdirp } from '../../../utils/fs.js'; import { createKeyedMutex } from '../../../utils/keyed-mutex.js'; import type { MemoryLogger } from './index.js'; +import type { BlobScope } from '../../storage/index.js'; + +// ─── Where a document lives ──────────────────────────────────────────────── + +/** + * One memory document, addressed however its tier stores it. + * + * `target` is what a {@link WriteResult} names, so it is the caller's own + * vocabulary rather than a physical location — an agent that reads + * `memory/space.md` should see that spelling back, not wherever the bytes + * happen to sit. + */ +export interface MemoryDocument { + readonly target: string; + read(): Promise; + write(body: string): Promise; +} + +/** A document that is a real file — the Workspace-scoped tiers. */ +export function fileDocument( + absPath: string, + parentDir: string, +): MemoryDocument { + return { + target: absPath, + async read(): Promise { + return existsSync(absPath) ? readFileSync(absPath, 'utf8') : null; + }, + async write(body: string): Promise { + mkdirp(parentDir); + atomicWriteText(absPath, body); + }, + }; +} + +/** A document that is a blob — a Space's memory body. */ +export function blobDocument( + scope: BlobScope, + name: string, + target: string, +): MemoryDocument { + return { + target, + async read(): Promise { + const bytes = await scope.read(name); + return bytes === null ? null : bytes.toString('utf8'); + }, + async write(body: string): Promise { + await scope.put(name, Buffer.from(body, 'utf8')); + }, + }; +} // ─── Concurrency guards ──────────────────────────────────────────────────── // @@ -71,10 +127,8 @@ export const SKILL_CREATE_RATIONALE_MIN = 20; interface CommonArgs { tier: MemoryTier; - /** Absolute path, already sandbox-validated by the caller. */ - absPath: string; - /** Directory to `mkdirp` before writing. */ - parentDir: string; + /** Where the bytes live, already sandbox-validated by the caller. */ + document: MemoryDocument; /** * Required when `tier === 'skill'` — used to invalidate the user * skill loader cache after a successful write. Ignored otherwise. @@ -102,24 +156,24 @@ export async function overwriteMemoryFile( return runForTier(args.tier, () => doOverwrite(args)); } -function doOverwrite(args: OverwriteArgs): WriteResult { +async function doOverwrite(args: OverwriteArgs): Promise { + const { target } = args.document; try { const body = ensureTrailingNewline(args.body); if (args.tier !== 'skill') { const capCheck = checkCap(body); - if (!capCheck.ok) return reject(args.absPath, capCheck.reason); + if (!capCheck.ok) return reject(target, capCheck.reason); } - mkdirp(args.parentDir); - atomicWriteText(args.absPath, body); + await args.document.write(body); if (args.tier === 'skill' && args.skillId) { invalidateUserSkill(args.skillId); } args.logger?.info( - `[memory] ${args.tier} overwritten at ${args.absPath} (${body.length} bytes)`, + `[memory] ${args.tier} overwritten at ${target} (${body.length} bytes)`, ); - return { ok: true, target: args.absPath, reason: 'overwritten' }; + return { ok: true, target, reason: 'overwritten' }; } catch (err) { - return rejectFromError(err, args.absPath); + return rejectFromError(err, target); } } @@ -147,34 +201,32 @@ export async function replaceStringInMemoryFile( return runForTier(args.tier, () => doReplaceString(args)); } -function doReplaceString(args: ReplaceStringArgs): WriteResult { +async function doReplaceString(args: ReplaceStringArgs): Promise { + const { target } = args.document; try { if (typeof args.oldString !== 'string' || args.oldString.length === 0) { - return reject(args.absPath, 'oldString is required and non-empty'); + return reject(target, 'oldString is required and non-empty'); } if (typeof args.newString !== 'string') { - return reject(args.absPath, 'newString is required (use "" to delete)'); + return reject(target, 'newString is required (use "" to delete)'); } if (args.oldString === args.newString) { - return reject(args.absPath, 'oldString and newString are identical'); + return reject(target, 'oldString and newString are identical'); } - if (!existsSync(args.absPath)) { + const before = await args.document.read(); + if (before === null) { return reject( - args.absPath, + target, `file does not exist — use mode="overwrite" to create it`, ); } - const before = readFileSync(args.absPath, 'utf8'); const idx = before.indexOf(args.oldString); if (idx === -1) { - return reject( - args.absPath, - 'oldString not found in file — no edit applied', - ); + return reject(target, 'oldString not found in file — no edit applied'); } if (before.indexOf(args.oldString, idx + 1) !== -1) { return reject( - args.absPath, + target, 'oldString matches multiple times — add more surrounding context to make it unique', ); } @@ -185,19 +237,18 @@ function doReplaceString(args: ReplaceStringArgs): WriteResult { ); if (args.tier !== 'skill') { const capCheck = checkCap(after); - if (!capCheck.ok) return reject(args.absPath, capCheck.reason); + if (!capCheck.ok) return reject(target, capCheck.reason); } - mkdirp(args.parentDir); - atomicWriteText(args.absPath, after); + await args.document.write(after); if (args.tier === 'skill' && args.skillId) { invalidateUserSkill(args.skillId); } args.logger?.info( - `[memory] ${args.tier} edited at ${args.absPath} (${after.length} bytes)`, + `[memory] ${args.tier} edited at ${target} (${after.length} bytes)`, ); - return { ok: true, target: args.absPath, reason: 'edited' }; + return { ok: true, target, reason: 'edited' }; } catch (err) { - return rejectFromError(err, args.absPath); + return rejectFromError(err, target); } } diff --git a/apps/server/src/modules/agent/tools/executor.ts b/apps/server/src/modules/agent/tools/executor.ts index deca047e6..f63d6c75e 100644 --- a/apps/server/src/modules/agent/tools/executor.ts +++ b/apps/server/src/modules/agent/tools/executor.ts @@ -119,10 +119,10 @@ export async function executeTool( }; const withCanvasId = (value: Record, toolName: string) => ({ ...value, canvasId: requireCanvasId(toolName) }) as unknown as T; - const withReadCanvasId = ( + const withReadCanvasId = async ( value: Record, toolName: string, - ): T => { + ): Promise => { const ownerCanvasId = requireCanvasId(toolName); const requested = value.targetCanvasId; if (requested !== undefined && typeof requested !== 'string') { @@ -131,7 +131,7 @@ export async function executeTool( const { targetCanvasId: _targetCanvasId, ...toolArgs } = value; return { ...toolArgs, - canvasId: resolveWorldReadCanvasId(ownerCanvasId, requested), + canvasId: await resolveWorldReadCanvasId(ownerCanvasId, requested), } as unknown as T; }; @@ -141,31 +141,31 @@ export async function executeTool( case 'get_space_outline': return handleGetCanvasOutline( - withReadCanvasId(args, 'get_space_outline'), + await withReadCanvasId(args, 'get_space_outline'), ); case 'inspect_nodes': return handleInspectNodes( - withReadCanvasId(args, 'inspect_nodes'), + await withReadCanvasId(args, 'inspect_nodes'), ); case 'inspect_edges': return handleInspectEdges( - withReadCanvasId(args, 'inspect_edges'), + await withReadCanvasId(args, 'inspect_edges'), ); case 'grep': - return handleGrep(withReadCanvasId(args, 'grep')); + return handleGrep(await withReadCanvasId(args, 'grep')); case 'find': - return handleFind(withReadCanvasId(args, 'find')); + return handleFind(await withReadCanvasId(args, 'find')); case 'ls': - return handleLs(withReadCanvasId(args, 'ls')); + return handleLs(await withReadCanvasId(args, 'ls')); case 'read': { const ownerCanvasId = requireCanvasId('read'); - const readArgs = withReadCanvasId(args, 'read'); + const readArgs = await withReadCanvasId(args, 'read'); return handleRead( readArgs, readArgs.canvasId === ownerCanvasId ? context?.readSet : undefined, diff --git a/apps/server/src/modules/agent/tools/handlers/canvas-query.ts b/apps/server/src/modules/agent/tools/handlers/canvas-query.ts index 178b7fe2a..13a268204 100644 --- a/apps/server/src/modules/agent/tools/handlers/canvas-query.ts +++ b/apps/server/src/modules/agent/tools/handlers/canvas-query.ts @@ -50,7 +50,7 @@ export type InspectEdgesArgs = InspectEdgesQueryParams & { export async function handleGetCanvasOutline( args: GetCanvasOutlineArgs, ): Promise { - const outline = buildCanvasOutline(args.canvasId, { + const outline = await buildCanvasOutline(args.canvasId, { includePreviews: args.includePreviews, includeStyle: args.includeStyle, }); @@ -64,7 +64,7 @@ export async function handleInspectNodes( args: InspectNodesArgs, ): Promise { const { canvasId, ...predicates } = args; - const result = inspectNodes(canvasId, predicates); + const result = await inspectNodes(canvasId, predicates); // `inspectNodes` returns either a result object or `{ error }` when a // referenced node is missing. Promote the error case to a throw so // pi-agent-core flags the tool result as `isError: true`. @@ -78,7 +78,7 @@ export async function handleInspectEdges( args: InspectEdgesArgs, ): Promise { const { canvasId, ...predicates } = args; - const result = inspectEdges(canvasId, predicates); + const result = await inspectEdges(canvasId, predicates); if ('error' in result) { throw new Error(result.error); } diff --git a/apps/server/src/modules/agent/tools/handlers/fs-read.ts b/apps/server/src/modules/agent/tools/handlers/fs-read.ts index 780da71bf..2931d9857 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-read.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-read.ts @@ -39,7 +39,7 @@ import { normalizeRel, safeResolve } from './fs-sandbox.js'; import { readSkillFile, resolveSkillPath } from '../../../../prompt/index.js'; import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js'; import { IMAGE_MIME_MAP, isVisionImageMime } from '../../../../utils/mime.js'; -import { getCanvasStore } from '../../../storage/index.js'; +import { space } from '../../../storage/index.js'; import { readCanvasMemory, readWorkspaceMemory } from '../../memory/index.js'; import type { readParamsSchema } from '../definitions.js'; @@ -76,25 +76,26 @@ const NODE_FILE_RE = /^nodes\/[^/]+\.md$/; * content, and the same revision used by executor CAS. When supplied, the * turn read-set receives that revision for guarded follow-up writes. */ -function projectNodeRead( +async function projectNodeRead( rel: string, canvasId: string, fileText: string, readSet?: Map, -): +): Promise< | { content: string; frontmatter: Record; rev: string; } - | undefined { + | undefined +> { if (!NODE_FILE_RE.test(rel)) return undefined; const parsed = parseFrontmatter(fileText); const rawId = parsed.meta['id']; const nodeId = typeof rawId === 'string' && rawId ? rawId : undefined; if (!nodeId) return undefined; try { - const nc = getCanvasStore(canvasId).readNode(nodeId); + const nc = (await space(canvasId).nodes.read(nodeId))?.record; if (!nc) return undefined; const rev = nodeRevisionOf({ content: nc.content, @@ -176,7 +177,7 @@ export async function handleRead( 'memory/space.md is Space-scoped but no canvasId is bound to this request', ); } - content = readCanvasMemory(args.canvasId); + content = await readCanvasMemory(args.canvasId); } else { throw new Error( `Unknown memory path "${rel}". Valid: memory/user.md, memory/space.md`, @@ -260,7 +261,7 @@ export async function handleRead( } const text = buf.toString('utf8'); - const nodeRead = projectNodeRead(rel, args.canvasId, text, readSet); + const nodeRead = await projectNodeRead(rel, args.canvasId, text, readSet); if (nodeRead) { return renderTextResponse(rel, nodeRead.content, offset, limit, { frontmatter: nodeRead.frontmatter, 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..076739824 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 { space, unavailableCapabilityMessage } from '../../../storage/index.js'; // ─── Always-skipped directory names ───────────────────────────────────────── @@ -143,7 +143,14 @@ export function safeResolve(canvasId: string, rel: string): string { ) { throw new Error(`Invalid canvasId: ${canvasId}`); } - const root = spaceDirectory(canvasId); + // Disk-only, and declared as such: `builtin-file-tools` is a + // capability-matrix entry, so an operator learns this when they select a + // profile rather than when an agent calls a tool. Refusing here is the + // backstop behind that declaration, phrased the same way. + const tree = space(canvasId).diskTree; + if (!tree) + throw new Error(unavailableCapabilityMessage('builtin-file-tools')); + 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 +162,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('/'); @@ -296,22 +316,27 @@ export interface NodeMeta { * frontmatter `id:` plus `space.json` metadata. * Returns `null` otherwise. */ -export function makeNodeLookup( +export async function makeNodeLookup( canvasId: string, -): (canvasRelPath: string) => NodeMeta | null { +): Promise<(canvasRelPath: string) => NodeMeta | null> { + // The Space record is read up front rather than inside the lazy build: the + // returned lookup is called synchronously from the search passes, and only + // this half of the work crosses the storage port. The directory scan below + // stays where it is — the built-in file tools are Disk-only (§6.4.3, + // disposition A) and this maps real filenames to records. + let file; + try { + file = await space(canvasId).read(); + } catch { + file = null; + } + let cache: Map | null = null; const ensure = (): Map => { if (cache) return cache; const byId = new Map(); const byPath = new Map(); - - let file; - try { - file = getCanvasStore(canvasId).read(); - } catch { - file = null; - } if (file) { const nodes = (file.state.nodes ?? []) as Array>; for (const n of nodes) { diff --git a/apps/server/src/modules/agent/tools/handlers/fs-search.ts b/apps/server/src/modules/agent/tools/handlers/fs-search.ts index d4fe60e43..a90c68b85 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-search.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-search.ts @@ -168,7 +168,7 @@ export async function handleGrep(args: GrepArgs): Promise { const globRe = effectiveGlob ? globToRegExp(effectiveGlob) : null; const effectiveLimit = Math.max(1, limit ?? DEFAULT_GREP_LIMIT); const ctxN = Math.max(0, ctxLines ?? 0); - const lookup = makeNodeLookup(args.canvasId); + const lookup = await makeNodeLookup(args.canvasId); const deadline = Date.now() + GREP_DEADLINE_MS; // Enumerate candidate files, recording each as a canvas-relative @@ -289,7 +289,7 @@ export async function handleFind(args: FindArgs): Promise { } const effectiveLimit = Math.max(1, limit ?? DEFAULT_FIND_LIMIT); - const lookup = makeNodeLookup(args.canvasId); + const lookup = await makeNodeLookup(args.canvasId); const results: Array> = []; let truncated = false; diff --git a/apps/server/src/modules/agent/tools/handlers/fs-write.test.ts b/apps/server/src/modules/agent/tools/handlers/fs-write.test.ts index 5f17d9ae1..ce4d010aa 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-write.test.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-write.test.ts @@ -13,26 +13,27 @@ * ✓ result envelope shape (`{ ok, target, reason }`) for both ok and reject */ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, -} from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { handleFsWrite } from './fs-write.js'; +import { createSpace, space } from '../../../storage/index.js'; import { - canvasMemoryPath, userSkillsDir, workspaceMemoryPath, } from '../../../workspace/paths.js'; import { setWorkspacePath } from '../../../workspace.js'; +/** Where Disk places the Space memory blob. */ +function memoryBlobFile(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('Expected the Disk backend in this test'); + return join(tree.directory(), '.memory', 'space.md'); +} + interface ParsedResult { ok: boolean; target: string; @@ -46,15 +47,14 @@ function parse(raw: string): ParsedResult { let tmp: string; const canvasId = 'cv-fs-write-test'; -beforeEach(() => { +beforeEach(async () => { tmp = mkdtempSync(join(tmpdir(), 'huabu-fs-write-')); setWorkspacePath(tmp); - // `canvasRoot(canvasId)` falls back to `/` when the - // canvas-dir index has no entry for the id (see `canvasDirName` in - // `storage/backends/disk/canvas-dirs.ts`). We just need the directory to - // exist so - // writes can land in it. - mkdirSync(join(tmp, canvasId), { recursive: true }); + // A real Space, not just a directory: the memory body is a blob under the + // Space's own scope now, and blobs may only be written for a Space whose + // record exists. + const created = await createSpace(canvasId, canvasId); + if (!created.ok) throw new Error('Expected to create the Space'); }); afterEach(() => { @@ -172,8 +172,9 @@ describe('handleFsWrite — overwrite', () => { } as never), ); expect(r.ok).toBe(true); - expect(r.target).toBe(canvasMemoryPath(canvasId)); - expect(readFileSync(canvasMemoryPath(canvasId), 'utf8')).toBe( + // The agent sees back the path it asked for, not wherever the bytes sit. + expect(r.target).toBe('memory/space.md'); + expect(readFileSync(memoryBlobFile(canvasId), 'utf8')).toBe( 'canvas briefing\n', ); }); diff --git a/apps/server/src/modules/agent/tools/handlers/fs-write.ts b/apps/server/src/modules/agent/tools/handlers/fs-write.ts index 501d8527a..da6c039ff 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-write.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-write.ts @@ -32,20 +32,19 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { normalizeRel } from './fs-sandbox.js'; -import { - canvasMemoryDir, - settingDir, - userSkillsDir, -} from '../../../workspace/paths.js'; +import { space, SPACE_MEMORY_BLOB_NAME } from '../../../storage/index.js'; +import { settingDir, userSkillsDir } from '../../../workspace/paths.js'; import { resolveLongTermPath, resolveUserSkillPath, - resolveWorkingMemoryPath, } from '../../memory/sandbox.js'; import { + blobDocument, + fileDocument, overwriteMemoryFile, replaceStringInMemoryFile, SKILL_CREATE_RATIONALE_MIN, + type MemoryDocument, type MemoryTier, type WriteResult, } from '../../memory/writers.js'; @@ -79,7 +78,7 @@ export async function handleFsWrite(args: FsWriteArgs): Promise { // future literal added to the schema produces a clear runtime error // until the dispatcher is updated. return reject( - target.absPath, + target.document.target, `unknown mode: ${(args as { mode?: string }).mode ?? '(missing)'}`, ); } @@ -88,9 +87,16 @@ export async function handleFsWrite(args: FsWriteArgs): Promise { interface ResolvedTarget { tier: MemoryTier; - absPath: string; - parentDir: string; + /** Where the bytes live — a file for the Workspace tiers, a blob for a + * Space's memory body. */ + document: MemoryDocument; skillId?: string; + /** + * Skill tier only. The create-rationale rule asks whether the file exists + * yet and where its parent is, which are questions about a real path — and + * user skills are Workspace-scoped, so they have one. + */ + skillPaths?: { absPath: string; parentDir: string }; /** The normalised relative path, for error messages. */ path: string; } @@ -106,8 +112,7 @@ function resolveTarget( if (rel === 'memory/user.md') { return { tier: 'workspace', - absPath: resolveLongTermPath(), - parentDir: settingDir(), + document: fileDocument(resolveLongTermPath(), settingDir()), path: rel, }; } @@ -122,8 +127,15 @@ function resolveTarget( } return { tier: 'canvas', - absPath: resolveWorkingMemoryPath(args.canvasId), - parentDir: canvasMemoryDir(args.canvasId), + // A blob under the Space's own memory scope (proposal §6.4.3, + // disposition D). The agent still sees the path it asked for; the + // sandbox resolver this used to need is gone, and so is the ad-hoc + // write against a directory it assembled. + document: blobDocument( + space(args.canvasId).memory, + SPACE_MEMORY_BLOB_NAME, + rel, + ), path: rel, }; } @@ -144,10 +156,11 @@ function resolveTarget( const skillId = segs[1]; try { const absPath = resolveUserSkillPath(skillId); + const parentDir = path.dirname(absPath); return { tier: 'skill', - absPath, - parentDir: path.dirname(absPath), + document: fileDocument(absPath, parentDir), + skillPaths: { absPath, parentDir }, skillId, path: rel, }; @@ -169,7 +182,7 @@ async function handleOverwrite( target: ResolvedTarget, ): Promise { if (typeof args.body !== 'string') { - return reject(target.absPath, 'mode="overwrite" requires "body"'); + return reject(target.document.target, 'mode="overwrite" requires "body"'); } // Skill create rule: when the target file does not yet exist on a @@ -178,11 +191,15 @@ async function handleOverwrite( // hard-require a rationale (≥ N chars) here so the LLM cannot // sneak a new skill in without justifying why an existing one // could not be edited. - if (target.tier === 'skill' && !existsSync(target.absPath)) { + if ( + target.tier === 'skill' && + target.skillPaths && + !existsSync(target.skillPaths.absPath) + ) { const r = (args.rationale ?? '').trim(); if (r.length < SKILL_CREATE_RATIONALE_MIN) { return reject( - target.absPath, + target.document.target, `skill create rejected: provide a "rationale" (>= ${SKILL_CREATE_RATIONALE_MIN} chars) explaining why no existing skill can be updated`, ); } @@ -190,9 +207,9 @@ async function handleOverwrite( // exists even before the writer's mkdirp runs, so a malformed // resolveUserSkillPath result surfaces as a clear error here // rather than mid-write. - if (!target.parentDir.startsWith(userSkillsDir())) { + if (!target.skillPaths.parentDir.startsWith(userSkillsDir())) { return reject( - target.absPath, + target.document.target, 'skill target escapes the user skills sandbox', ); } @@ -200,8 +217,7 @@ async function handleOverwrite( const result = await overwriteMemoryFile({ tier: target.tier, - absPath: target.absPath, - parentDir: target.parentDir, + document: target.document, skillId: target.skillId, body: args.body, }); @@ -213,15 +229,20 @@ async function handleReplaceString( target: ResolvedTarget, ): Promise { if (typeof args.oldString !== 'string') { - return reject(target.absPath, 'mode="replace_string" requires "oldString"'); + return reject( + target.document.target, + 'mode="replace_string" requires "oldString"', + ); } if (typeof args.newString !== 'string') { - return reject(target.absPath, 'mode="replace_string" requires "newString"'); + return reject( + target.document.target, + 'mode="replace_string" requires "newString"', + ); } const result = await replaceStringInMemoryFile({ tier: target.tier, - absPath: target.absPath, - parentDir: target.parentDir, + document: target.document, skillId: target.skillId, oldString: args.oldString, newString: args.newString, 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..4655bee5e 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 artifacts = space(args.canvasId).artifacts; const refImages: Array<{ key: string; bytes: Buffer }> = []; for (const key of refs) { if (typeof key !== 'string' || !key.trim()) { @@ -139,7 +139,7 @@ export async function handleGenerateImage( `Invalid reference artifact key: ${JSON.stringify(key)}. Use the bare \`src\` string returned by snapshot_nodes.`, ); } - const bytes = await blobs.read(key); + const bytes = await artifacts.read(key); if (!bytes) { throw new Error( `Reference artifact "${key}" not found on canvas ${args.canvasId}. It may have been deleted.`, @@ -272,7 +272,7 @@ export async function handleGenerateImage( // `canvas_commands` insert or embeds them in a note body — from // user-uploaded artifacts that should never be auto-collected. const name = `${createId('gen')}.png`; - await blobs.put(name, png); + await artifacts.put(name, png); // The requested size string ("auto" included) drives what we // report back; gpt-image-* generally honours the request size, and diff --git a/apps/server/src/modules/artifact/artifact.route.test.ts b/apps/server/src/modules/artifact/artifact.route.test.ts index 3010fbec7..fee9b55b6 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, @@ -86,9 +86,8 @@ function installDeleteBlock(canvasId: string): { init: () => current.blobs.init(), health: () => current.blobs.health(), close: () => current.blobs.close(), - scope(ref) { - const delegate = current.blobs.scope(ref); - return { + space(id) { + const observe = (delegate: BlobScope): BlobScope => ({ put(name, body) { putCalls += 1; return delegate.put(name, body); @@ -100,12 +99,19 @@ function installDeleteBlock(canvasId: string): { list: () => delegate.list(), materialize: (name) => delegate.materialize(name), async deleteAll() { - if (ref.canvasId === canvasId) { + if (id === canvasId) { started.resolve(); await release.promise; } await delegate.deleteAll(); }, + }); + const areas = current.blobs.space(id); + return { + artifacts: observe(areas.artifacts), + guide: observe(areas.guide), + memory: observe(areas.memory), + uploads: observe(areas.uploads), }; }, }; @@ -194,7 +200,7 @@ describe('artifact route', () => { }); expect(upload.statusCode).toBe(500); - expect(await canvasBlobs('missing').list()).toEqual([]); + expect(await space('missing').artifacts.list()).toEqual([]); await app.close(); }); @@ -234,7 +240,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').artifacts.list()).toEqual([]); } finally { blocker.releaseDelete(); blocker.restore(); @@ -244,7 +250,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').artifacts.put('a.png', png); const res = await app.inject({ method: 'GET', @@ -260,7 +266,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').artifacts.put('a.png', png); const first = await app.inject({ method: 'GET', @@ -295,12 +301,18 @@ describe('artifact route', () => { return { ok: false, kind: 'disk' }; }, async close() {}, - scope() { - return { + space() { + const unavailable = { async head() { throw new Error('blob backend unavailable'); }, } as unknown as BlobScope; + return { + artifacts: unavailable, + guide: unavailable, + memory: unavailable, + uploads: unavailable, + }; }, }; const restore = setStorageForTesting({ ...current, blobs: failingBlobs }); @@ -332,7 +344,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').artifacts.put('a.png', png); const res = await app.inject({ method: 'POST', @@ -346,8 +358,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').artifacts.read(uri)).toEqual(png); + expect(await space('src-canvas').artifacts.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..c544480e3 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).artifacts.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 artifacts = space(canvasId).artifacts; const safeName = path.basename(filename); // `.mhtml` snapshots are stored as proper multipart/related MHTML @@ -91,7 +91,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { // we strip the wrapper on the fly and serve the inner HTML as // `text/html` so no browser-side MHTML handler is required. if (safeName.toLowerCase().endsWith('.mhtml')) { - const buffer = await blobs.read(safeName); + const buffer = await artifacts.read(safeName); if (!buffer) { return reply.code(404).send({ message: 'Artifact not found' }); } @@ -115,7 +115,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { ); } - const served = await sendBlob(request, reply, blobs, safeName); + const served = await sendBlob(request, reply, artifacts, safeName); if (!served) { return reply.code(404).send({ message: 'Artifact not found' }); } @@ -151,7 +151,7 @@ const artifactRoute: FastifyPluginAsync = async (fastify) => { let buffer: Buffer | null; try { - buffer = await canvasBlobs(srcCanvasId).read(srcKey); + buffer = await space(srcCanvasId).artifacts.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).artifacts.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..d5962c383 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).artifacts.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-command-router.ts b/apps/server/src/modules/canvas/canvas-command-router.ts index fc38724ed..e85d14d6f 100644 --- a/apps/server/src/modules/canvas/canvas-command-router.ts +++ b/apps/server/src/modules/canvas/canvas-command-router.ts @@ -23,11 +23,7 @@ import { } from './canvas-executor.js'; import { reconcileWorldPortals } from './world-portals.js'; import { createKeyedMutex } from '../../utils/keyed-mutex.js'; -import { - listCanvasDirEntries, - requireWorldCanvasId, -} from '../storage/canvas-dirs.js'; -import { getCanvasStore } from '../storage/index.js'; +import { getStructuredStore, space } from '../storage/index.js'; type PortalCommand = Extract; const withPortalRoutingMutex = createKeyedMutex(); @@ -210,7 +206,7 @@ async function ensureCanonicalPortals( worldCanvasId: string, commands: readonly PortalCommand[], ): Promise { - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const world = (await space(worldCanvasId).read()) as StoredCanvas | null; const targets = new Set(); for (const node of (world?.state.nodes ?? []) as NestableNode[]) { const target = portalTarget(node); @@ -249,14 +245,15 @@ async function executeCanvasCommandsOnHostInternal( assertConsistentDesiredStates(portalCommands); - const worldCanvasId = requireWorldCanvasId(); + const spaces = getStructuredStore().spaces(); + const worldCanvasId = await spaces.worldId(); if (!routingLockHeld) { return withPortalRoutingMutex(worldCanvasId, () => executeCanvasCommandsOnHostInternal(input, true), ); } await ensureCanonicalPortals(worldCanvasId, portalCommands); - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const world = (await space(worldCanvasId).read()) as StoredCanvas | null; if (!world || !Array.isArray(world.state.nodes)) { throw new CanvasCommandRoutingError('World Canvas is unavailable'); } @@ -335,18 +332,39 @@ async function executeCanvasCommandsOnHostInternal( } const liveCanvasIds = new Set( - listCanvasDirEntries().map((entry) => entry.id), + (await spaces.list()).map((summary) => summary.canvasId), ); - const sourceStates = new Map(); - const readSource = (canvasId: string): SourceState | null => { - if (!sourceStates.has(canvasId)) { - sourceStates.set( - canvasId, - sourceStateOf(getCanvasStore(canvasId).read() as StoredCanvas | null), - ); + + // Every source Space the passes below can ask about, read once up front. + // The set is knowable without running them: a source is either named by a + // Portal-Pin update or referenced by a World node, and both are already in + // hand. That keeps the reads to what this command touches while letting the + // passes themselves stay synchronous. + const referencedCanvasIds = new Set(); + for (const command of portalCommands) { + for (const update of command.updates) { + referencedCanvasIds.add(update.sourceCanvasId); } - return sourceStates.get(canvasId) ?? null; - }; + } + for (const node of worldNodes) { + const target = referenceTarget(node); + if (target) referencedCanvasIds.add(target.canvasId); + } + const sourceStates = new Map( + await Promise.all( + [...referencedCanvasIds].map( + async (canvasId) => + [ + canvasId, + sourceStateOf( + (await space(canvasId).read()) as StoredCanvas | null, + ), + ] as const, + ), + ), + ); + const readSource = (canvasId: string): SourceState | null => + sourceStates.get(canvasId) ?? null; const requested = new Map(); for (const command of portalCommands) { diff --git a/apps/server/src/modules/canvas/canvas-content-cas.test.ts b/apps/server/src/modules/canvas/canvas-content-cas.test.ts index bb0b55b5f..80ab39231 100644 --- a/apps/server/src/modules/canvas/canvas-content-cas.test.ts +++ b/apps/server/src/modules/canvas/canvas-content-cas.test.ts @@ -531,7 +531,12 @@ describe('artifact presence hydration', () => { return { ok: true, kind: 'disk' }; }, async close() {}, - scope: () => scope, + space: () => ({ + artifacts: scope, + guide: scope, + memory: scope, + uploads: scope, + }), }; } diff --git a/apps/server/src/modules/canvas/canvas-executor.test.ts b/apps/server/src/modules/canvas/canvas-executor.test.ts index 8bf897503..cf54e7269 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').artifacts.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').artifacts.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..a8a9b47bd 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -64,15 +64,13 @@ import { } from './world-portal-policy.js'; import { getLogger } from '../../utils/logger.js'; import { - canvasBlobs, - getCanvasStore, - getStructuredStore, + space, withCanvasMutex, type BlobScope, type CanvasFile, - type CanvasStore, type DeltaLogEntry, type NodeContent, + type NodeSnapshot, type SpaceNodeMutation, } from '../storage/index.js'; @@ -140,7 +138,7 @@ function stripNodesForCanvas(nodes: readonly CanvasNode[]): CanvasNode[] { } function hydrateNodes( - store: CanvasStore, + records: ReadonlyMap, nodes: readonly CanvasNode[], ): CanvasNode[] { return nodes.map((node) => { @@ -149,12 +147,7 @@ function hydrateNodes( const nodeType = typeof node.type === 'string' ? node.type : ''; if (!MD_BACKED_NODE_TYPES.has(nodeType)) return { ...node }; - let content: NodeContent | null = null; - try { - content = store.readNode(nodeId); - } catch { - content = null; - } + const content = records.get(nodeId)?.record ?? null; if (!content) return { ...node }; const data: Record = { ...(node.data ?? {}) }; @@ -375,7 +368,7 @@ async function aspectHeightForWidth( width: number, ): Promise { try { - const dim = await readImageDimensions(canvasBlobs(canvasId), src); + const dim = await readImageDimensions(space(canvasId).artifacts, src); if (!dim?.width || !dim?.height || dim.width <= 0 || dim.height <= 0) { return null; } @@ -692,11 +685,7 @@ export async function executeOnServer( // writes to the same canvas. Idempotent for values that are already artifact // keys / `/api/` URLs / `data:` URIs. if (originator.source === 'agent') { - commands = await importForeignNodeSources( - getCanvasStore(canvasId), - canvasId, - commands, - ); + commands = await importForeignNodeSources(canvasId, commands); // For image nodes with only width specified, calculate height from actual // image aspect ratio. This ensures correct proportions for all image sources. @@ -704,18 +693,21 @@ export async function executeOnServer( } return await withCanvasMutex(canvasId, async () => { - const store = getCanvasStore(canvasId); - const handle = getStructuredStore().space(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); + // Executor prestate is whole-Space work: every md-backed node in the + // topology needs its stored content before the engine sees it. + const records = await handle.nodes.list(); + const fromVersion = canvas.version; // Hydrate per-node content from .md sidecars before the engine sees // the prestate — handlers like MERGE_NODE_DATA need the current // `data.content` to merge against, but topology never carries it. const prestateNodes = hydrateNodes( - store, + records, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; @@ -1123,14 +1115,17 @@ export async function applyDeltasOnServer(input: { const { canvasId, originator, runId } = input; return await withCanvasMutex(canvasId, async () => { - const store = getCanvasStore(canvasId); - const handle = getStructuredStore().space(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); + // Executor prestate is whole-Space work: every md-backed node in the + // topology needs its stored content before the engine sees it. + const records = await handle.nodes.list(); + const fromVersion = canvas.version; const prestateNodes = hydrateNodes( - store, + records, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; diff --git a/apps/server/src/modules/canvas/canvas-search.test.ts b/apps/server/src/modules/canvas/canvas-search.test.ts index 9677f057b..22cc2c60c 100644 --- a/apps/server/src/modules/canvas/canvas-search.test.ts +++ b/apps/server/src/modules/canvas/canvas-search.test.ts @@ -7,7 +7,7 @@ * Covers the streaming {@link searchCanvas} driver. The route layer is * a thin NDJSON adapter, so the interesting behaviour — tiered emission * order, field filtering, snippet construction, limits, abort semantics - * — all lives here and is exercised against a fake `CanvasStore` whose + * — all lives here and is exercised against a fake `Space` whose * `streamAllNodes` walks an in-memory snapshot. Production reads sidecars * off disk, but the scanner takes a callback so the two are wire-compatible. */ @@ -24,7 +24,7 @@ import { import { createChatSubmission } from '../agent/agenetes/handle.js'; import type { ChatEnvelope } from '../agent/conversation/envelope.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent, NodeSnapshot, Space } from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; import type { CanvasSearchEvent, CanvasSearchRequest } from '@huabu/shared'; @@ -76,16 +76,16 @@ function mkEdge( } /** - * Build a duck-typed `CanvasStore` that satisfies just the two methods + * Build a duck-typed `Space` that satisfies just the two members * `searchCanvas` actually reads from: `read()` (for the static node + edge * shape) and `streamAllNodes()` (for the sidecar bodies). The rest of the * store surface is irrelevant here, so we cast through `unknown`. */ -function makeFakeStore(opts: { +function makeFakeSpace(opts: { nodes: readonly SearchableNode[]; contents: readonly NodeContent[]; edges?: readonly SearchableEdge[]; -}): CanvasStore { +}): Space { const stateNodes = opts.nodes.map((n) => ({ id: n.id, type: n.type, @@ -100,21 +100,25 @@ function makeFakeStore(opts: { })); const fake = { canvasId: 'test-canvas', - read: () => ({ state: { nodes: stateNodes, edges: stateEdges } }), - streamAllNodes: async ( - onNode: (id: string, content: NodeContent) => void, - signal?: { readonly aborted: boolean }, - ): Promise> => { - const map = new Map(); - for (const c of opts.contents) { - if (signal?.aborted) return map; - map.set(c.nodeId, c); - onNode(c.nodeId, c); - } - return map; + read: async () => ({ state: { nodes: stateNodes, edges: stateEdges } }), + nodes: { + canvasId: 'test-canvas', + stream: async ( + onNode: (snapshot: NodeSnapshot) => void, + options?: { signal?: { readonly aborted: boolean } }, + ): Promise> => { + const map = new Map(); + for (const c of opts.contents) { + if (options?.signal?.aborted) return map; + const snapshot = { record: c, revision: `rev-${c.nodeId}` }; + map.set(c.nodeId, snapshot); + onNode(snapshot); + } + return map; + }, }, }; - return fake as unknown as CanvasStore; + return fake as unknown as Space; } async function collect( @@ -124,9 +128,9 @@ async function collect( signal?: AbortSignal, edges?: SearchableEdge[], ): Promise { - const store = makeFakeStore({ nodes, contents, edges }); + const handle = makeFakeSpace({ nodes, contents, edges }); const events: CanvasSearchEvent[] = []; - await searchCanvas(store, request, (e) => events.push(e), signal); + await searchCanvas(handle, request, (e) => events.push(e), signal); return events; } @@ -390,9 +394,9 @@ describe('searchCanvas — abort', () => { } const ctrl = new AbortController(); const events: CanvasSearchEvent[] = []; - const store = makeFakeStore({ nodes, contents }); + const handle = makeFakeSpace({ nodes, contents }); await searchCanvas( - store, + handle, { query: 'hit' }, (e) => { events.push(e); diff --git a/apps/server/src/modules/canvas/canvas-search.ts b/apps/server/src/modules/canvas/canvas-search.ts index 17e0754d6..3be81d2c1 100644 --- a/apps/server/src/modules/canvas/canvas-search.ts +++ b/apps/server/src/modules/canvas/canvas-search.ts @@ -46,7 +46,7 @@ import { agenetes } from '../agent/agenetes/drivers.js'; import { chatEnvelopeFromSubmission } from '../agent/agenetes/handle.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent, Space } from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; /** Window of characters shown around each match in `snippet`. */ @@ -531,20 +531,20 @@ export function extractSearchableEdges(state: unknown): SearchableEdge[] { } /** - * Drive a search directly off a {@link CanvasStore}. + * Drive a search off one Space. * - * Streams meta-tier matches as each sidecar lands (no `await`-all + * Streams meta-tier matches as each record lands (no `await`-all * barrier in front of the first emit). Content tier runs after the * stream settles, scanning the in-memory cache that was built as a - * side effect of the meta walk — zero extra disk reads. + * side effect of the meta walk — zero extra reads. */ export async function searchCanvas( - store: CanvasStore, + handle: Space, request: CanvasSearchRequest, emit: (event: CanvasSearchEvent) => void, signal?: AbortSignal, ): Promise { - const file = store.read(); + const file = await handle.read(); if (!file) { emit({ type: 'error', message: 'Canvas not found' }); return; @@ -583,22 +583,31 @@ export async function searchCanvas( // invokes the callback synchronously as each file resolves, so a // match against the very first parsed file ships before any of the // remaining files are even opened. - const contentByNodeId = await store.streamAllNodes((id, content) => { - if (signal?.aborted) return; - if (!wantsMeta) return; - if (totalEmitted >= limit) { - truncated = true; - return; - } - const node = candidateById.get(id); - // Skip sidecars that belong to nodes filtered out by - // `nodeId` / `nodeTypes` — we still read them (the directory - // walk is unfiltered) but they contribute nothing here. - if (!node) return; - scanNodeMeta(node, content, fields, needleLower, needleLen, (m) => - tryEmitMatch('meta', m), - ); - }, signal); + const streamed = await handle.nodes.stream( + (snapshot) => { + const id = snapshot.record.nodeId; + const content = snapshot.record; + if (signal?.aborted) return; + if (!wantsMeta) return; + if (totalEmitted >= limit) { + truncated = true; + return; + } + const node = candidateById.get(id); + // Skip sidecars that belong to nodes filtered out by + // `nodeId` / `nodeTypes` — we still read them (the directory + // walk is unfiltered) but they contribute nothing here. + if (!node) return; + scanNodeMeta(node, content, fields, needleLower, needleLen, (m) => + tryEmitMatch('meta', m), + ); + }, + signal ? { signal } : undefined, + ); + const contentByNodeId = new Map(); + for (const [id, snapshot] of streamed) { + contentByNodeId.set(id, snapshot.record); + } if (signal?.aborted) return; @@ -677,7 +686,7 @@ export async function searchCanvas( const label = content?.label ?? null; scanNodeConversation( node, - store.canvasId, + handle.canvasId, label, needleLower, needleLen, diff --git a/apps/server/src/modules/canvas/canvas-spatial.test.ts b/apps/server/src/modules/canvas/canvas-spatial.test.ts index 70b98ceb0..a95900181 100644 --- a/apps/server/src/modules/canvas/canvas-spatial.test.ts +++ b/apps/server/src/modules/canvas/canvas-spatial.test.ts @@ -50,7 +50,7 @@ function seed(canvasId: string, nodes: SeedNode[]): void { }); } -function byId(result: ReturnType): Map< +function byId(result: Awaited>): Map< string, { position: { x: number; y: number }; @@ -113,18 +113,18 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { ]); } - it('root node reports position == absolutePosition', () => { + it('root node reports position == absolutePosition', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['root'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['root'] })); expect(m.get('root')).toEqual({ position: { x: 100, y: 100 }, absolutePosition: { x: 100, y: 100 }, }); }); - it('framed child reports parent-local position and world absolutePosition', () => { + it('framed child reports parent-local position and world absolutePosition', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['child'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['child'] })); expect(m.get('child')).toEqual({ // raw stored value = frame-relative position: { x: 50, y: 60 }, @@ -133,9 +133,9 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { }); }); - it('resolves absolutePosition through a nested frame chain', () => { + it('resolves absolutePosition through a nested frame chain', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['grandchild'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['grandchild'] })); expect(m.get('grandchild')).toEqual({ // raw stored value = relative to inner frame position: { x: 10, y: 20 }, @@ -144,9 +144,9 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { }); }); - it('get_canvas_outline emits the same dual fields', () => { + it('get_canvas_outline emits the same dual fields', async () => { seedScene(); - const outline = buildCanvasOutline(CANVAS); + const outline = await buildCanvasOutline(CANVAS); if (!outline) throw new Error('no outline'); const child = outline.nodes.find((n) => n.id === 'child'); expect(child?.position).toEqual({ x: 50, y: 60 }); diff --git a/apps/server/src/modules/canvas/canvas-spatial.ts b/apps/server/src/modules/canvas/canvas-spatial.ts index d96911163..06a2245f1 100644 --- a/apps/server/src/modules/canvas/canvas-spatial.ts +++ b/apps/server/src/modules/canvas/canvas-spatial.ts @@ -53,10 +53,10 @@ import { } from '@huabu/shared/canvas-engine'; import { describeNode, nodeLabel, type NodeInput } from './node-prompt.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { AgentNodeOutline } from '../agent/node-ref.js'; -import type { CanvasFile } from '../storage/canvas-store.js'; +import type { CanvasFile } from '../storage/index.js'; import type { CanvasNodeType, CardinalDirection, @@ -282,14 +282,20 @@ export interface CanvasOutlineOpts { * Build the one-shot "map" of a canvas: every node's geometry + edges + * spatial clusters. Returns `null` when the canvas does not exist. */ -export function buildCanvasOutline( +export async function buildCanvasOutline( canvasId: string, opts: CanvasOutlineOpts = {}, -): CanvasOutline | null { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return null; + // Whole-Space work: the outline describes every node, so one scan is the + // shape (§12.6.1). It also replaces the lazy per-node reads the memo below + // used to guard, which no async port could serve from inside a synchronous + // map. + const records = await handle.nodes.list(); + const bundle = buildSpatialBundle(canvas); const summary = buildSpatialSummary(bundle.spatialNodes, bundle.edges); @@ -311,25 +317,20 @@ export function buildCanvasOutline( // Resolve display labels from the sidecar (topology never carries // them), memoized so a frame referenced as many nodes' parent is read once. - const labelMemo = new Map(); - const memoLabel = (id: string): string | undefined => { - if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); - labelMemo.set(id, l); - return l; - }; + const labelOf = (id: string): string | undefined => + nodeLabel(records.get(id)?.record ?? null); const nodes: CanvasOutlineNode[] = bundle.spatialNodes.map((s) => { const raw = bundle.rawById.get(s.id); - const parentLabel = s.parentId ? memoLabel(s.parentId) : undefined; + const parentLabel = s.parentId ? labelOf(s.parentId) : undefined; const style = opts.includeStyle ? readVisualStyle(raw) : undefined; const out: CanvasOutlineNode = describeNode( - store, { ...spatialNodeInput(s, raw, parentLabel), ...(style ? { style } : {}), }, 'outline', + records.get(s.id)?.record ?? null, ); // The shared builder attaches `summary` (authored abstract) and // `preview` (raw body excerpt) from the sidecar; both are text scan @@ -449,25 +450,23 @@ const DEFAULT_INSPECT_LIMIT = 50; * direction, edgeIds, hops, clusterId) are computed during filtering * and merged into the final result row. */ -export function inspectNodes( +export async function inspectNodes( canvasId: string, args: InspectNodesArgs, -): InspectNodesResult | InspectNodesError { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return { error: `Canvas ${canvasId} not found` }; const bundle = buildSpatialBundle(canvas); + // An unfiltered inspect returns every node, and `labelPattern` has to test + // every candidate, so the candidate set is the Space (§12.6.1). + const records = await handle.nodes.list(); // Display labels come from the sidecar (topology never carries them); // memoize so the `labelPattern` filter and the result map read each once. - const labelMemo = new Map(); - const memoLabel = (id: string): string | undefined => { - if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); - labelMemo.set(id, l); - return l; - }; + const memoLabel = (id: string): string | undefined => + nodeLabel(records.get(id)?.record ?? null); // Per-node derived fields accumulated during filter passes. const derived = new Map>(); @@ -713,9 +712,9 @@ export function inspectNodes( const raw = bundle.rawById.get(s.id); const parentLabel = s.parentId ? memoLabel(s.parentId) : undefined; const base = describeNode( - store, spatialNodeInput(s, raw, parentLabel), 'outline', + records.get(s.id)?.record ?? null, ); // Inspect deliberately omits text hints (`summary` / `preview`); agents // that need text use `get_canvas_outline({ includePreviews: true })` or @@ -815,12 +814,11 @@ export type InspectEdgesError = { error: string }; * `inspect_nodes({ connectedTo }).edgeIds` or directly from a styling * task, so even the unfiltered case is bounded in practice. */ -export function inspectEdges( +export async function inspectEdges( canvasId: string, args: InspectEdgesArgs, -): InspectEdgesResult | InspectEdgesError { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) return { error: `Canvas ${canvasId} not found` }; const bundle = buildSpatialBundle(canvas); diff --git a/apps/server/src/modules/canvas/canvas.route.test.ts b/apps/server/src/modules/canvas/canvas.route.test.ts index bbfeac7a8..6e5e68558 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').artifacts.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).artifacts.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 b6ffedde6..634e663cf 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -2,8 +2,8 @@ // Licensed under the MIT license. import { spawn } from 'node:child_process'; -import { createWriteStream, existsSync, mkdirSync, renameSync } from 'node:fs'; -import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { createWriteStream, existsSync, mkdirSync } from 'node:fs'; +import { mkdir, rm, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -43,31 +43,24 @@ import { WorldReferenceResolutionError, } from './world-reference-resolver.js'; import { MAX_UPLOAD_BYTES } from '../../upload-limits.js'; -import { toSafeFilename } from '../../utils/naming.js'; import { ARTIFACT_URL_REGEX } from '../artifact/utils.js'; import { getPreprocessDispatcher, getProfile } from '../preprocessing/index.js'; import { stripOfficeparserPreamble } from '../preprocessing/loaders/office-strip.js'; +import { isWorldCanvasId } from '../storage/canvas-dirs.js'; import { - isWorldCanvasId, - refreshCanvasDirIndex, - registerCanvasDir, - suggestCanvasDir, -} from '../storage/canvas-dirs.js'; -import { - canvasBlobs, + space, createSpace, deleteSpace, - getCanvasStore, + stageSpaceImport, + unavailableCapabilityMessage, getStructuredStore, - spaceDirectory, type CanvasFile, + type NodeContent, + type Space, type UpdateNodeOutcome, updateNode, } from '../storage/index.js'; -import { nodesDir, SPACE_JSON_FILENAME } from '../storage/paths.js'; -import { getWorkspacePath } from '../workspace.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; import type { CanvasNodeType } from '@huabu/shared'; import type { ApiResult, @@ -337,7 +330,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).artifacts.hasMany([key])).has(key); return (candidate) => candidate === key && exists; } @@ -357,10 +350,10 @@ async function singleArtifactProbe( * callers can rely on identity-based diffing. */ function hydrateOneNode( - store: CanvasStore, node: NodeLike, artifactExists: (key: string) => boolean, - preloaded?: NodeContent | null, + nodeContent: NodeContent | null, + duplicateSidecars: readonly string[], ): NodeLike { const nodeId = typeof node.id === 'string' ? node.id : ''; if (!nodeId) return node; @@ -375,17 +368,6 @@ function hydrateOneNode( // is the only source of truth for those fields, so we read it before // any check that depends on them (notably the artifact-missing probe, // which needs the hydrated `src`). - let nodeContent: NodeContent | null; - if (preloaded !== undefined) { - nodeContent = preloaded; - } else { - try { - nodeContent = store.readNode(nodeId); - } catch { - nodeContent = null; - } - } - if (!nodeContent) { if (MD_BACKED_NODE_TYPES.has(nodeType)) { data['contentMissing'] = true; @@ -407,9 +389,9 @@ function hydrateOneNode( // duplicate. The duplicate set was already populated by the // `readAllNodes()` scan that produced `preloaded`, so this is a cheap // in-memory lookup with no extra disk I/O. - if (store.isDuplicateNode(nodeId)) { + if (duplicateSidecars.length > 0) { data['contentDuplicate'] = true; - data['duplicateFiles'] = store.duplicateNodeFiles(nodeId); + data['duplicateFiles'] = [...duplicateSidecars]; } else { if ('contentDuplicate' in data) { delete data['contentDuplicate']; @@ -508,13 +490,17 @@ function hydrateOneNode( * load on cold cache. */ async function hydrateNodeContent( - store: CanvasStore, + handle: Space, nodes: NodeLike[], ): Promise { // Read sidecars first because they are the source of truth for `src`. // Probe only the keys referenced by artifact-backed nodes; enumerating the // entire scope would make hydration cost grow with unrelated blob count. - const contentByNodeId = await store.readAllNodes(); + const records = await handle.nodes.list(); + const contentByNodeId = new Map(); + for (const [nodeId, snapshot] of records) { + contentByNodeId.set(nodeId, snapshot.record); + } const referenced = new Set(); for (const node of nodes) { const nodeType = typeof node.type === 'string' ? node.type : ''; @@ -527,16 +513,16 @@ async function hydrateNodeContent( const present = referenced.size === 0 ? new Set() - : await canvasBlobs(store.canvasId).hasMany([...referenced]); + : await handle.artifacts.hasMany([...referenced]); const artifactExists = (key: string): boolean => present.has(key); return nodes.map((node) => { const nodeId = typeof node.id === 'string' ? node.id : ''; return hydrateOneNode( - store, node, artifactExists, contentByNodeId.get(nodeId) ?? null, + handle.diskTree?.duplicateSidecars(nodeId) ?? [], ); }); } @@ -882,20 +868,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }>('/:canvasId/nodes/:nodeId/content', async function (request, reply) { const { canvasId, nodeId } = request.params; - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } - // Reconcile the cached node index against disk before this read. - // Only re-scans when warranted: a node already flagged duplicate - // always re-reads (so a hand-resolved duplicate is detected — the - // cheap count probe alone can't see that case), otherwise it falls - // back to the names-only staleness probe. Keeps the common healthy - // read off the full content rescan. - store.revalidateNodeForRead(nodeId); - // Find this node in the persisted canvas state so we know its type // (without it we can't apply the artifact-missing branch). For // nodes that exist in `.md` but not in canvas state we fall back @@ -905,9 +883,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { let nodeType = stateNode && typeof stateNode.type === 'string' ? stateNode.type : ''; + // The port's single-node read reconciles the adapter's cached index + // against storage before answering, so a hand-resolved duplicate or an + // external rename is picked up here rather than needing its own probe. let existing: NodeContent | null = null; try { - existing = store.readNode(nodeId); + existing = (await handle.nodes.read(nodeId))?.record ?? null; } catch { existing = null; } @@ -934,13 +915,14 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Reuse the batched hydration helper so single-node and whole- // canvas reads stay in lock-step. const hydrated = hydrateOneNode( - store, { id: nodeId, type: nodeType, data: { ...(stateNode?.data ?? {}) }, }, - await singleArtifactProbe(store.canvasId, existing.src), + await singleArtifactProbe(canvasId, existing.src), + existing, + handle.diskTree?.duplicateSidecars(nodeId) ?? [], ); const data = (hydrated.data ?? {}) as Record; @@ -1010,9 +992,10 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { const { nodeType, trigger, snapshot, previousSnapshot, options } = parsed.data; const dispatcher = getPreprocessDispatcher(); - const store = getCanvasStore(canvasId); - - if (MD_BACKED_NODE_TYPES.has(nodeType) && !store.readNode(nodeId)) { + if ( + MD_BACKED_NODE_TYPES.has(nodeType) && + !(await space(canvasId).nodes.read(nodeId)) + ) { return reply.send({ nodeId, success: false, @@ -1098,8 +1081,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { if (isWorldCanvasId(canvasId)) { await reconcileWorldPortals(); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); @@ -1108,7 +1091,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Hydrate node content from the per-canvas store so clients always // receive fresh markdown bodies. const nodes = canvas.state.nodes as NodeLike[]; - const hydratedNodes = await hydrateNodeContent(store, nodes); + const hydratedNodes = await hydrateNodeContent(handle, nodes); return reply.send({ canvasId: canvas.canvasId, @@ -1570,12 +1553,13 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { Reply: ApiResult; }>('/:canvasId/reveal-nodes', async function (request, reply) { const { canvasId } = request.params; - const store = getCanvasStore(canvasId); - if (!store.read()) { + if (!(await space(canvasId).read())) { return reply.code(404).send({ message: 'Canvas not found' }); } - const dir = nodesDir(canvasId); - if (!existsSync(dir)) { + // Disk-only, declared as `reveal-space-folder`: the feature *is* "show me + // this in Finder", so a backend without a folder has nothing to show. + const dir = space(canvasId).diskTree?.nodesDirectory(); + if (dir === undefined || !existsSync(dir)) { return reply.code(404).send({ message: 'Nodes folder not found' }); } // Fire-and-forget: `openInFileManager` is best-effort and never @@ -1604,14 +1588,17 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { } const includeHistory = parsedQuery.data.includeHistory !== 'false'; - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const canvas = await space(canvasId).read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } - const canvasDir = spaceDirectory(canvasId); - if (!existsSync(canvasDir)) { + // Disk-only, declared as `space-bundle-export` in the capability matrix; + // 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' }); } @@ -1671,13 +1658,17 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Stream the upload to a temp zip file const tmpZip = path.join(tmpdir(), `${createId('import')}.zip`); const targetCanvasId = createId('canvas'); - // Extract into a hidden staging dir so `scanWorkspace()` ignores it - // (it skips dot-prefixed entries) and the as-yet-unrenamed dir cannot - // be picked up by `read()`'s self-heal as a canvas titled ``. - const stagingDir = path.join( - getWorkspacePath(), - `.import-${targetCanvasId}`, - ); + // Where an imported Space lands is the backend's business — the + // staging location, the title-derived directory, the record filename, + // and the index entry are all layout. This route owns the `.huabu.zip` + // format and nothing else (proposal §12.6.2). + const staged = stageSpaceImport(targetCanvasId); + if (!staged) { + return reply.code(400).send({ + message: unavailableCapabilityMessage('space-bundle-import'), + }); + } + const stagingDir = staged.stagingDirectory; let stagingCleanedUp = false; try { await new Promise((resolve, reject) => { @@ -1743,64 +1734,33 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { await writeFile(dest, new Uint8Array(buf)); }); - // Rewrite the topology file so canvasId matches the new directory. - // New bundles carry `space.json`; still accept legacy `canvas.json` - // exports and normalise them to the new name on the way in. - const stagedJsonPath = path.join(stagingDir, SPACE_JSON_FILENAME); - const legacyJsonPath = path.join(stagingDir, 'canvas.json'); - const sourceJsonPath = existsSync(stagedJsonPath) - ? stagedJsonPath - : existsSync(legacyJsonPath) - ? legacyJsonPath - : null; - if (!sourceJsonPath) { - await rm(stagingDir, { recursive: true, force: true }); + const parsed = await staged.readRecord(); + if (!parsed) { + await staged.discard(); stagingCleanedUp = true; return reply.code(400).send({ - message: 'Invalid bundle: missing space.json', + message: 'Invalid bundle: missing Space record', }); } - const raw = await readFile(sourceJsonPath, 'utf-8'); - const parsed = JSON.parse(raw) as CanvasFile; const sourceCanvasId = parsed.canvasId; const importedManifest = manifest as ImportManifest | null; const targetTitle = importedManifest?.title ?? parsed.title ?? 'Imported canvas'; - const finalDirName = suggestCanvasDir(targetTitle, targetCanvasId); - const safeFromTitle = toSafeFilename(targetTitle, targetCanvasId); - const dedupeSuffix = - finalDirName === safeFromTitle - ? '' - : finalDirName.slice(safeFromTitle.length); - const resolvedTitle = - dedupeSuffix === '' ? targetTitle : targetTitle + dedupeSuffix; - - const remapped: CanvasFile = { + + // Artifact URLs are the bundle's own vocabulary, so they are rewritten + // here; where the result is filed is not, so `publish` decides that — + // including the de-duplication suffix it may have to add to the title. + await staged.publish({ ...parsed, canvasId: targetCanvasId, - title: resolvedTitle, + title: targetTitle, state: rewriteCanvasArtifactUrls( parsed.state, sourceCanvasId, targetCanvasId, ), - }; - // Always persist under the new name so the storage layer (which - // addresses `space.json`) can find it; drop a legacy source file. - await writeFile(stagedJsonPath, JSON.stringify(remapped)); - if (sourceJsonPath !== stagedJsonPath) { - await rm(sourceJsonPath, { force: true }); - } - - // Move the staged dir into its final, title-derived location so - // the on-disk basename matches the title and `read()` will not - // self-heal-overwrite the title with the staging dir basename on - // the next access. - const finalDir = path.join(getWorkspacePath(), finalDirName); - renameSync(stagingDir, finalDir); + }); stagingCleanedUp = true; - registerCanvasDir(targetCanvasId, finalDirName, resolvedTitle); - refreshCanvasDirIndex(); const response: ImportCanvasResponse = { canvasId: targetCanvasId, @@ -1840,9 +1800,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); - if (!canvas) { + const handle = space(canvasId); + if (!(await handle.read())) { return reply.code(404).send({ message: 'Canvas not found' }); } @@ -1880,7 +1839,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { request.raw.on('close', onClose); try { - await searchCanvas(store, parsed.data, writeEvent, abort.signal); + await searchCanvas(handle, parsed.data, writeEvent, abort.signal); } catch (err) { request.log.error({ err, canvasId }, 'Canvas search failed'); writeEvent({ diff --git a/apps/server/src/modules/canvas/external-watcher.test.ts b/apps/server/src/modules/canvas/external-watcher.test.ts index cf3de2474..8203416e8 100644 --- a/apps/server/src/modules/canvas/external-watcher.test.ts +++ b/apps/server/src/modules/canvas/external-watcher.test.ts @@ -68,18 +68,19 @@ vi.mock('../storage/canvas-dirs.js', () => ({ listAllCanvasDirEntries: () => canvasDirs.list(), })); -const canvasStore = vi.hoisted(() => ({ - read: vi.fn(() => ({ state: { nodes: [] } })), +const spaceHandle = vi.hoisted(() => ({ + read: vi.fn(async () => ({ state: { nodes: [] } })), })); -// The facade is stubbed for the store, but the handle helpers must stay the -// real ones: these cases drive `withSpaceDirHandlesReleased` and assert the -// watcher released its handles, which only works if both sides share the one -// module instance that holds the registry. +// The facade is stubbed for the Space handle, but the directory-handle +// helpers must stay the real ones: these cases drive +// `withSpaceDirHandlesReleased` and assert the watcher released its handles, +// which only works if both sides share the one module instance that holds the +// registry. vi.mock('../storage/index.js', async () => { const handles = await import('../storage/backends/disk/space-dir-handles.js'); return { - getCanvasStore: () => canvasStore, + space: () => spaceHandle, registerSpaceDirHandleOwner: handles.registerSpaceDirHandleOwner, withSpaceDirHandlesReleased: handles.withSpaceDirHandlesReleased, }; @@ -146,7 +147,7 @@ beforeEach(() => { isFile: () => true, isDirectory: () => false, }); - canvasStore.read.mockClear(); + spaceHandle.read.mockClear(); canvasDirs.list.mockReturnValue([]); state.configured = true; }); @@ -260,15 +261,17 @@ describe('openExternalNoteSession', () => { ]); const first = await openExternalNoteSession('canvas-a', vi.fn()); + const afterFirst = spaceHandle.read.mock.calls.length; const second = await openExternalNoteSession('canvas-a', vi.fn()); const readPaths = fileIO.readFile.mock.calls.map(([filePath]) => filePath); expect( readPaths.filter((filePath) => filePath.endsWith('.md')), ).toHaveLength(2); - expect( - readPaths.filter((filePath) => filePath.endsWith('space.json')), - ).toHaveLength(1); + // Topology comes from the Space record now, not a file beside `nodes/`, + // so the invariant is counted on the port: the second subscriber shares + // the first's scan and pays only for its own snapshot. + expect(spaceHandle.read.mock.calls.length - afterFirst).toBe(1); expect(fileIO.readdir).toHaveBeenCalledTimes(1); expect(first.snapshot).toHaveLength(2); @@ -552,7 +555,7 @@ describe('openExternalNoteSession', () => { emitNativeWatcherEvent('later.md'); await vi.waitFor(() => { - expect(canvasStore.read).toHaveBeenCalled(); + expect(spaceHandle.read).toHaveBeenCalled(); }); expect(listener).not.toHaveBeenCalled(); @@ -687,7 +690,7 @@ describe('native note events', () => { // A repeat observation replaces the entry instead of duplicating it. emitNativeWatcherEvent('later.md'); await vi.waitFor(() => { - expect(canvasStore.read.mock.calls.length).toBeGreaterThan(1); + expect(spaceHandle.read.mock.calls.length).toBeGreaterThan(1); }); expect(events).toHaveLength(1); diff --git a/apps/server/src/modules/canvas/external-watcher.ts b/apps/server/src/modules/canvas/external-watcher.ts index fc37894a7..c20430521 100644 --- a/apps/server/src/modules/canvas/external-watcher.ts +++ b/apps/server/src/modules/canvas/external-watcher.ts @@ -33,9 +33,8 @@ import path from 'node:path'; import { getLogger } from '../../utils/logger.js'; import { parseFrontmatter } from '../../utils/markdown-frontmatter.js'; import { listAllCanvasDirEntries } from '../storage/canvas-dirs.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import { registerSpaceDirHandleOwner } from '../storage/index.js'; -import { SPACE_JSON_FILENAME } from '../storage/paths.js'; import { getWorkspacePath, isWorkspaceConfigured } from '../workspace.js'; import type { CanvasFile } from '../storage/index.js'; @@ -130,19 +129,24 @@ function noteIdsFromCanvas(canvas: CanvasFile | null): Set { return ids; } -function canvasNoteIds(canvasId: string): Set { - return noteIdsFromCanvas(getCanvasStore(canvasId).read()); +async function canvasNoteIds(canvasId: string): Promise> { + return noteIdsFromCanvas(await space(canvasId).read()); } +/** + * The scan's view of which notes the Space already knows. + * + * Read through the port rather than off the record file beside `nodes/`. The + * path read was equivalent only because Disk keeps the two together, and this + * question — what does the Space contain — is one every backend answers. + * Failure degrades to "knows nothing", as before: a scan that cannot read + * topology surfaces every file rather than silently hiding some. + */ async function readInitialCanvasNoteIds( - nodesPath: string, + canvasId: string, ): Promise> { try { - const raw = await readFile( - path.join(path.dirname(nodesPath), SPACE_JSON_FILENAME), - 'utf8', - ); - return noteIdsFromCanvas(JSON.parse(raw) as CanvasFile); + return await canvasNoteIds(canvasId); } catch { return new Set(); } @@ -195,8 +199,16 @@ function forgetItem(session: ActiveSpaceWatch, relativePath: string): void { emit(session, { type: 'removed', data: { relativePath } }); } -function snapshotOf(session: ActiveSpaceWatch): ExternalNoteItem[] { - const known = canvasNoteIds(session.canvasId); +/** + * `known` is passed in rather than read here because this must stay + * synchronous: it both reads and prunes `pendingItems`, and the caller relies + * on registering its listener and taking the snapshot without an await + * between them, so no event can slip through. + */ +function snapshotOf( + session: ActiveSpaceWatch, + known: ReadonlySet, +): ExternalNoteItem[] { const out: ExternalNoteItem[] = []; for (const [rel, item] of session.pendingItems) { if (item.noteId && known.has(item.noteId)) { @@ -228,7 +240,7 @@ function scheduleNodeEvent(session: ActiveSpaceWatch, basename: string): void { .then(async (fileStat) => { if (!fileStat.isFile()) return; const item = await buildItem(absPath, relativePath, () => - Promise.resolve(canvasNoteIds(session.canvasId)), + canvasNoteIds(session.canvasId), ); if (!item || !isSessionCurrent(session, stamp)) return; recordItem(session, item); @@ -674,7 +686,7 @@ async function runInitialScan(session: ActiveSpaceWatch): Promise { let topology: Promise> | null = null; const knownNoteIds = (): Promise> => - (topology ??= readInitialCanvasNoteIds(session.nodesPath)); + (topology ??= readInitialCanvasNoteIds(session.canvasId)); let nextIndex = 0; const worker = async (): Promise => { @@ -787,11 +799,13 @@ export async function openExternalNoteSession( await ensureInitialScan(active); } + const known = await canvasNoteIds(active.canvasId); + // Registering the listener and reading the snapshot must stay in one // synchronous block so no event can slip between them. if (released || !isSessionCurrent(active)) return { snapshot: [], close }; active.listeners.add(listener); - return { snapshot: snapshotOf(active), close }; + return { snapshot: snapshotOf(active, known), close }; } /** Remove and return a pending item — used by the import endpoint. */ diff --git a/apps/server/src/modules/canvas/external.route.ts b/apps/server/src/modules/canvas/external.route.ts index 0984daa1c..e5fc9e18a 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); + // Disk-only, declared as `external-note-discovery` in the capability + // matrix: it adopts 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..061b2f9ee 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); @@ -97,7 +105,6 @@ function seedWebNode(canvasId: string, nodeId: string, src: string): void { describe('importForeignNodeSources — web nodes', () => { it('relocates a locally-staged HTML upload into .artifacts/ and reclaims it', async () => { const canvasId = 'c-web-local'; - const store = getCanvasStore(canvasId); const uploadAbs = stageUpload( canvasId, 'index.html', @@ -117,7 +124,7 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); const src = firstSrc(out); // Rewritten to a bare artifact key… @@ -125,14 +132,13 @@ 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).artifacts.head(src)).not.toBeNull(); // …and the staging upload was reclaimed (move semantics). expect(existsSync(uploadAbs)).toBe(false); }); it('rejects a non-HTML local upload without reclaiming it', async () => { const canvasId = 'c-web-invalid-local'; - const store = getCanvasStore(canvasId); const uploadAbs = stageUpload(canvasId, 'document.pdf', 'not html'); const commands: CanvasCommand[] = [ @@ -148,7 +154,7 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstSrc(out)).toBe('upload/document.pdf'); expect(existsSync(uploadAbs)).toBe(true); @@ -156,7 +162,6 @@ describe('importForeignNodeSources — web nodes', () => { it('leaves a live remote URL untouched (never downloads it)', async () => { const canvasId = 'c-web-remote'; - const store = getCanvasStore(canvasId); const remote = 'https://example.com/some/page.html'; const commands: CanvasCommand[] = [ @@ -172,13 +177,12 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstSrc(out)).toBe(remote); }); it('leaves a data: URL untouched', async () => { const canvasId = 'c-web-data'; - const store = getCanvasStore(canvasId); const dataUrl = 'data:text/html,

inline

'; const commands: CanvasCommand[] = [ @@ -194,7 +198,7 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstSrc(out)).toBe(dataUrl); }); @@ -202,7 +206,6 @@ describe('importForeignNodeSources — web nodes', () => { const canvasId = 'c-web-merge-local'; const nodeId = 'node-web-merge-local'; seedWebNode(canvasId, nodeId, 'https://example.com/old'); - const store = getCanvasStore(canvasId); const uploadAbs = stageUpload( canvasId, 'replacement.html', @@ -215,13 +218,13 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); const src = firstPatchedSrc(out); 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).artifacts.head(src)).not.toBeNull(); expect(existsSync(uploadAbs)).toBe(false); }); @@ -229,7 +232,6 @@ describe('importForeignNodeSources — web nodes', () => { const canvasId = 'c-web-merge-remote'; const nodeId = 'node-web-merge-remote'; seedWebNode(canvasId, nodeId, 'https://example.com/old'); - const store = getCanvasStore(canvasId); const remote = 'https://example.com/new'; const commands: CanvasCommand[] = [ { @@ -238,7 +240,7 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstPatchedSrc(out)).toBe(remote); }); @@ -247,7 +249,6 @@ describe('importForeignNodeSources — web nodes', () => { describe('importForeignNodeSources — media nodes (regression)', () => { it('still relocates a locally-staged image upload', async () => { const canvasId = 'c-image-local'; - const store = getCanvasStore(canvasId); stageUpload(canvasId, 'pic.png', 'not-a-real-png-but-bytes'); const commands: CanvasCommand[] = [ @@ -263,18 +264,17 @@ describe('importForeignNodeSources — media nodes (regression)', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); const src = firstSrc(out); 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).artifacts.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'); @@ -298,7 +298,7 @@ describe('importForeignNodeSources — media nodes (regression)', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstSrc(out)).toBe('pic.png'); }); diff --git a/apps/server/src/modules/canvas/import-node-src.ts b/apps/server/src/modules/canvas/import-node-src.ts index 39837f701..c018257fc 100644 --- a/apps/server/src/modules/canvas/import-node-src.ts +++ b/apps/server/src/modules/canvas/import-node-src.ts @@ -39,11 +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 type { CanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; const log = getLogger('canvas.import-node-src'); @@ -136,7 +135,6 @@ function srcNormalizeMode(type: string): SrcNormalizeMode | null { * preserved so a single unreachable URL never fails the whole batch. */ export async function importForeignNodeSources( - store: CanvasStore, canvasId: string, commands: readonly CanvasCommand[], ): Promise { @@ -144,10 +142,10 @@ export async function importForeignNodeSources( // patches (CREATE_NODES carries `nodeType` inline). Node type is immutable, // so reading the pre-batch snapshot here is race-free. let typeById: Map | null = null; - const nodeType = (nodeId: string): string => { + const nodeType = async (nodeId: string): Promise => { if (!typeById) { typeById = new Map(); - const canvas = store.read(); + const canvas = await space(canvasId).read(); for (const raw of canvas?.state.nodes ?? []) { const n = raw as { id?: unknown; type?: unknown }; if (typeof n.id === 'string' && typeof n.type === 'string') { @@ -167,7 +165,6 @@ export async function importForeignNodeSources( if (!mode) return node; const data = node.data as Record | undefined; const key = await resolveImportedSrc( - store, canvasId, data?.['src'], mode.allowRemoteDownload, @@ -188,10 +185,9 @@ export async function importForeignNodeSources( if (cmd.type === 'MERGE_NODE_DATA') { const patches = await Promise.all( cmd.patches.map(async (entry) => { - const mode = srcNormalizeMode(nodeType(entry.nodeId)); + const mode = srcNormalizeMode(await nodeType(entry.nodeId)); if (!mode) return entry; const key = await resolveImportedSrc( - store, canvasId, entry.patch?.['src'], mode.allowRemoteDownload, @@ -222,7 +218,6 @@ export async function importForeignNodeSources( * preserved in place and left unchanged. */ async function resolveImportedSrc( - store: CanvasStore, canvasId: string, raw: unknown, allowRemoteDownload: boolean, @@ -248,7 +243,7 @@ async function resolveImportedSrc( ) { return null; } - return await downloadToArtifact(store, src, pathname); + return await downloadToArtifact(canvasId, src, pathname); } // Already an in-app API path — leave it for the web resolver. @@ -272,7 +267,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( @@ -295,12 +290,12 @@ async function resolveImportedSrc( return null; } - return await copyToArtifact(store, absPath, physicalRel); + return await copyToArtifact(canvasId, absPath, physicalRel); } /** Copy a canvas-local file into blob storage, returning the new key. */ async function copyToArtifact( - store: CanvasStore, + canvasId: string, absPath: string, physicalRel: string, ): Promise { @@ -309,7 +304,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(canvasId).artifacts.put(key, buffer); // Move semantics: reclaim RFS scratch uploads once they are safely // stored. Never delete user node files or other canvas content — @@ -334,7 +329,7 @@ async function copyToArtifact( /** Download an online resource into `.artifacts/`, returning the new key. */ async function downloadToArtifact( - store: CanvasStore, + canvasId: string, url: string, pathname: string, ): Promise { @@ -365,7 +360,7 @@ async function downloadToArtifact( } const ext = pickDownloadExt(pathname, contentType); const key = `${createId('artifact')}${ext}`; - await canvasBlobs(store.canvasId).put(key, buffer); + await space(canvasId).artifacts.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/node-neighbourhood.ts b/apps/server/src/modules/canvas/node-neighbourhood.ts index 350f3d78b..710d5e8d4 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.ts @@ -46,7 +46,7 @@ import { renderNodes, } from '../agent/conversation/prompt/node-element.js'; import { buildAgentNodePreview } from '../agent/node-ref.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { AgentNodePreview } from '../agent/node-ref.js'; import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; @@ -67,39 +67,39 @@ import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; * Owns the preview-extraction policy. Forwards each node through the * shared {@link extractAgentNodePreview} ladder * (`summary > content[:120] > src`) with two inputs merged in one - * pass: the on-disk frontmatter (via `readNode` — canonical for note - * nodes whose body lives in `nodes/.md`) and the inline - * `data.content` / `data.src` (text-on-canvas nodes whose body never - * touches disk). Per-node disk reads are memoized. + * pass: the stored node record (canonical for note nodes whose body lives + * outside the topology) and the inline `data.content` / `data.src` + * (text-on-canvas nodes whose body is never a separate record). + * + * Records are read once, up front, for the whole Space. The neighbourhood + * is a subset, so this reads more than the old lazy per-node path did on a + * sparse canvas — but a lazy read cannot cross an async port without making + * the pure walk below async too, and the walk already visits every node to + * build the spatial bundle (§12.6.1). */ -export function getNodeNeighbourhood( +export async function getNodeNeighbourhood( canvasId: string, anchorNodeId: string, -): NodeNeighbourhoodContext | null { - const canvas = getCanvasStore(canvasId).read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return null; const bundle = buildSpatialBundle(canvas); const target = bundle.spatialNodes.find((n) => n.id === anchorNodeId); if (!target) return null; - const store = getCanvasStore(canvasId); - const cache = new Map(); + const records = await handle.nodes.list(); // One assembler for every neighbour: the node carries whatever the spatial // bundle knows (id / type; its `data.label` is always empty), and - // `describeNode` fills label + body from the sidecar, then derives the + // `describeNode` fills label + body from the record, then derives the // `file=` path, preview line, and `rev` token (in lock-step with the RFS - // `ETag`). Memoized so a node referenced twice is read once. - const describe = (n: SpatialNode): AgentNodePreview => { - const hit = cache.get(n.id); - if (hit) return hit; - const preview = describeNode( - store, + // `ETag`). + const describe = (n: SpatialNode): AgentNodePreview => + describeNode( { id: n.id, type: n.type, ...(n.label ? { label: n.label } : {}) }, 'preview', + records.get(n.id)?.record ?? null, ); - cache.set(n.id, preview); - return preview; - }; return buildNodeNeighbourhoodContext( target, diff --git a/apps/server/src/modules/canvas/node-prompt.test.ts b/apps/server/src/modules/canvas/node-prompt.test.ts index e8f309348..8c509d479 100644 --- a/apps/server/src/modules/canvas/node-prompt.test.ts +++ b/apps/server/src/modules/canvas/node-prompt.test.ts @@ -6,42 +6,43 @@ * ({@link describeNode} / {@link nodeLabel} / {@link renderNodes}). * * Focus: the ONE rule — the caller's own fields win, anything missing is - * filled from the on-disk sidecar — plus the two agent-facing levels + * filled from the stored record — plus the two agent-facing levels * (`preview` / `outline`), `rev` presence, the summary/preview split, the - * null-store degenerate path, and the `` rendering. + * no-record degenerate path, and the `` rendering. */ import { describe, expect, it } from 'vitest'; import { describeNode, nodeLabel, renderNodes } from './node-prompt.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent } from '../storage/index.js'; -/** Minimal stub: only `readNode` is exercised by this module. */ -function stubStore( - nodes: Record | null>, -): CanvasStore { +/** One stored record, as a caller would have already read it. */ +function record( + id: string, + fields: Partial | null, +): NodeContent | null { + if (fields === null) return null; return { - readNode(id: string): NodeContent | null { - const n = nodes[id]; - if (n == null) return null; - return { - nodeId: id, - type: 'note', - label: null, - content: '', - ...n, - } as NodeContent; - }, - } as unknown as CanvasStore; + nodeId: id, + type: 'note', + label: null, + content: '', + ...fields, + } as NodeContent; } describe('describeNode — preview level', () => { it('fills label + body from the sidecar when the caller has none', () => { - const store = stubStore({ - n1: { label: 'My Note', content: 'Hello body', summary: 'Abstract' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'My Note', + content: 'Hello body', + summary: 'Abstract', + }), + ); expect(node.label).toBe('My Note'); // filename is derived from the (sidecar) label — the real on-disk path, @@ -53,19 +54,22 @@ describe('describeNode — preview level', () => { }); it("prefers the caller's own field over the sidecar (own wins)", () => { - const store = stubStore({ n1: { label: 'Sidecar Label', content: 'x' } }); const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Wire Label' }, 'preview', + record('n1', { label: 'Sidecar Label', content: 'x' }), ); expect(node.label).toBe('Wire Label'); expect(node.filename).toBe('nodes/Wire Label.md'); }); it('omits rev / summary / preview for a node with no body or summary', () => { - const store = stubStore({ n1: { label: 'Empty' } }); // content '' by default - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + // content '' by default + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { label: 'Empty' }), + ); expect(node.label).toBe('Empty'); expect(node.rev).toBeUndefined(); expect(node.summary).toBeUndefined(); @@ -73,54 +77,54 @@ describe('describeNode — preview level', () => { }); it('emits summary and preview as INDEPENDENT fields', () => { - const store = stubStore({ - n1: { label: 'L', summary: 'The abstract', content: 'The full body' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'L', + summary: 'The abstract', + content: 'The full body', + }), + ); expect(node.summary).toBe('The abstract'); expect(node.preview).toBe('The full body'); }); it('hashes rev from a source-backed node with no body', () => { - const store = stubStore({ - n1: { type: 'image', label: 'Pic', src: 'artifacts/a.png' }, - }); - const node = describeNode(store, { id: 'n1', type: 'image' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'image' }, + 'preview', + record('n1', { type: 'image', label: 'Pic', src: 'artifacts/a.png' }), + ); expect(typeof node.rev).toBe('string'); expect(node.preview).toBeUndefined(); // src is not a content preview }); - it('with a null store, uses only the caller-supplied fields', () => { + it('with no stored record, uses only the caller-supplied fields', () => { const node = describeNode( - null, { id: 'n1', type: 'note', label: 'L', content: 'Body' }, 'preview', + null, ); expect(node.label).toBe('L'); expect(node.preview).toBe('Body'); expect(typeof node.rev).toBe('string'); }); - it('with meta=null, forces "no sidecar" even when a store is passed', () => { - const store = stubStore({ n1: { label: 'Sidecar', content: 'x' } }); + it('treats a null record as "this node has none"', () => { const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Own' }, 'preview', null, ); expect(node.label).toBe('Own'); - expect(node.preview).toBeUndefined(); // sidecar body ignored + expect(node.preview).toBeUndefined(); }); }); describe('describeNode — outline level', () => { it('layers spatial metadata on top of the sidecar-sourced fields', () => { - const store = stubStore({ - n1: { label: 'Node', content: 'Body', summary: 'Sum' }, - }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -130,6 +134,7 @@ describe('describeNode — outline level', () => { style: { color: 'red' }, }, 'outline', + record('n1', { label: 'Node', content: 'Body', summary: 'Sum' }), ); expect(node.position).toEqual({ x: 10, y: 20 }); expect(node.size).toEqual({ width: 30, height: 40 }); @@ -144,9 +149,7 @@ describe('describeNode — outline level', () => { }); it('carries an explicit absolutePosition distinct from parent-local position', () => { - const store = stubStore({ n1: { label: 'Node' } }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -156,6 +159,7 @@ describe('describeNode — outline level', () => { parentFrame: { id: 'f1', label: 'Frame' }, }, 'outline', + record('n1', { label: 'Node' }), ); expect(node.position).toEqual({ x: 50, y: 60 }); expect(node.absolutePosition).toEqual({ x: 1050, y: 560 }); @@ -163,15 +167,15 @@ describe('describeNode — outline level', () => { }); describe('nodeLabel', () => { - it('returns the sidecar label', () => { - const store = stubStore({ n1: { label: 'Frame Title' } }); - expect(nodeLabel(store, 'n1')).toBe('Frame Title'); + it('returns the record label', () => { + expect(nodeLabel(record('n1', { label: 'Frame Title' }))).toBe( + 'Frame Title', + ); }); - it('returns undefined when the node has no sidecar or no label', () => { - const store = stubStore({ n1: null, n2: { label: null } }); - expect(nodeLabel(store, 'n1')).toBeUndefined(); - expect(nodeLabel(store, 'n2')).toBeUndefined(); + it('returns undefined when there is no record or no label', () => { + expect(nodeLabel(record('n1', null))).toBeUndefined(); + expect(nodeLabel(record('n2', { label: null }))).toBeUndefined(); }); }); @@ -205,10 +209,15 @@ describe('renderNodes', () => { }); it('renders the summary/preview split from describeNode end-to-end', () => { - const store = stubStore({ - n1: { label: 'Risks', summary: 'FX exposure', content: 'Full body' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'Risks', + summary: 'FX exposure', + content: 'Full body', + }), + ); const xml = renderNodes([node]); expect(xml).toBe( ', +async function readSidecarString( + handle: Space, nodeId: string, key: 'src', -): string | null { - const sidecar = store.readNode(nodeId); - if (!sidecar) return null; - const value = sidecar[key]; +): Promise { + const record = (await handle.nodes.read(nodeId))?.record; + if (!record) return null; + const value = record[key]; return typeof value === 'string' && value.length > 0 ? value : null; } @@ -440,17 +441,17 @@ export interface ContextImage { * this image. */ async function loadContextImage( - store: ReturnType, + handle: Space, node: CanvasNode, ): Promise { - const src = readSidecarString(store, node.id, 'src'); + const src = await readSidecarString(handle, node.id, 'src'); if (!src) return null; const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; 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 handle.artifacts.read(src); if (!bytes) return null; return { node, resolvedSrc: src, bytes, mimeType, width, height }; } @@ -795,15 +796,15 @@ async function resampleImageBytes( * so repeated calls with the same parameters are O(1) cache hits. */ async function maybeResizeImageArtifact( - store: ReturnType, + handle: Space, src: string, maxEdge: number, ): Promise<{ src: string; width: number; height: number } | null> { - const blobs = canvasBlobs(store.canvasId); + const artifacts = handle.artifacts; const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; if (!mimeType) return null; - const bytes = await blobs.read(src); + const bytes = await artifacts.read(src); if (!bytes) return null; const dims = readImageDimensions(bytes, mimeType); if (!dims) return null; @@ -815,7 +816,7 @@ async function maybeResizeImageArtifact( const originalStem = path.basename(src, path.extname(src)); const id = `${originalStem}-resized-${maxEdge}`; const filename = `${id}.png`; - const cachedBytes = await blobs.read(filename); + const cachedBytes = await artifacts.read(filename); if (cachedBytes) { // Re-derive dimensions from the cached blob so the result is // accurate without paying for another resvg pass. @@ -836,7 +837,7 @@ async function maybeResizeImageArtifact( dims.height, maxEdge, ); - await blobs.put(filename, resized.png); + await artifacts.put(filename, resized.png); return { src: filename, width: resized.width, height: resized.height }; } @@ -860,8 +861,8 @@ export async function snapshotNodesToArtifacts( Math.min(SPACE_SNAPSHOT_MAX_PIXELS, args.maxPixels ?? CLUSTER_MAX_PIXELS), ); - const store = getCanvasStore(args.canvasId); - const canvas = store.read(); + const handle = space(args.canvasId); + const canvas = await handle.read(); if (!canvas) { throw new SnapshotNodeError( `Canvas ${args.canvasId} not found`, @@ -974,7 +975,7 @@ export async function snapshotNodesToArtifacts( // resvg downscale, instead of building a full composite SVG. if (cluster.length === 1 && cluster[0].type === 'image') { const entry = cluster[0]; - const src = readSidecarString(store, entry.node.id, 'src'); + const src = await readSidecarString(handle, entry.node.id, 'src'); if (!src) { if (entry.fromFrame) continue; throw new SnapshotNodeError( @@ -982,7 +983,7 @@ export async function snapshotNodesToArtifacts( 'invalid_snapshot_request', ); } - const resized = await maybeResizeImageArtifact(store, src, maxEdge); + const resized = await maybeResizeImageArtifact(handle, src, maxEdge); if (resized) { results.push({ src: resized.src, @@ -1012,7 +1013,7 @@ export async function snapshotNodesToArtifacts( const contextImages: ContextImage[] = []; for (const entry of imageEntries) { - const loaded = await loadContextImage(store, entry.node); + const loaded = await loadContextImage(handle, entry.node); if (loaded) { contextImages.push(loaded); continue; @@ -1025,7 +1026,7 @@ export async function snapshotNodesToArtifacts( // (the strokes will still render; losing one backdrop is // preferable to failing the whole batch). if (entry.fromFrame) continue; - const src = readSidecarString(store, entry.node.id, 'src'); + const src = await readSidecarString(handle, entry.node.id, 'src'); if (!src) { throw new SnapshotNodeError( `Node ${entry.node.id} (image) has no src — nothing to return. The artifact may have been deleted, or the node's markdown sidecar (nodes/