diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts index 74b072e71..7855910e1 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 { readCanvas } from '../canvas/space-read.js'; import type { ExecuteOnServerOutput } from '../canvas/canvas-executor.js'; @@ -87,7 +87,9 @@ interface StoredNode { interface AgentNodeServiceDependencies { getProfileRegistry: () => AgentProfileRegistryPort | null; - readCanvasNodes: (canvasId: string) => StoredNode[] | null; + readCanvasNodes: ( + canvasId: string, + ) => Promise | StoredNode[] | null; execute: (input: { canvasId: string; commands: readonly CanvasCommand[]; @@ -95,8 +97,10 @@ interface AgentNodeServiceDependencies { }) => Promise; } -function defaultReadCanvasNodes(canvasId: string): StoredNode[] | null { - const canvas = getCanvasStore(canvasId).read(); +async function defaultReadCanvasNodes( + canvasId: string, +): Promise { + const canvas = await readCanvas(canvasId); if (!canvas) return null; return canvas.state.nodes as StoredNode[]; } @@ -153,11 +157,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 readCanvas(canvasId); if (!canvas) { throw new AgentNodeCreationError( 'canvas_not_found', @@ -201,7 +205,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..630e2d8d5 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.test.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.test.ts @@ -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,39 @@ 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( - createResolver([selectable]).resolveFixedAgentNode( + await createResolver([selectable]).resolveFixedAgentNode( 'canvas-a', 'thread-a', ), ).toBeNull(); expect( - createResolver([FIXED_NODE]).resolveFixedAgentNode( + await createResolver([FIXED_NODE]).resolveFixedAgentNode( 'canvas-a', 'thread-other', ), ).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( - createResolver([selectable]).resolveAgentNodeId('canvas-a', 'thread-a'), + await createResolver([selectable]).resolveAgentNodeId( + 'canvas-a', + 'thread-a', + ), ).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 +106,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 +131,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..85034f2dc 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 { readCanvas, readCanvasNode } from '../canvas/space-read.js'; interface StoredNode { id: string; @@ -23,8 +23,13 @@ interface StoredNode { } interface ResolverDependencies { - readCanvasNodes: (canvasId: string) => StoredNode[] | null; - readNodeContent: (canvasId: string, nodeId: string) => string | null; + readCanvasNodes: ( + canvasId: string, + ) => Promise | StoredNode[] | null; + readNodeContent: ( + canvasId: string, + nodeId: string, + ) => Promise | string | null; } export interface FixedAgentNodeTarget { @@ -55,12 +60,12 @@ export class AgentThreadResolutionError extends Error { } const DEFAULT_DEPENDENCIES: ResolverDependencies = { - readCanvasNodes: (canvasId) => { - const canvas = getCanvasStore(canvasId).read(); + readCanvasNodes: async (canvasId) => { + const canvas = await readCanvas(canvasId); return canvas ? (canvas.state.nodes as StoredNode[]) : null; }, - readNodeContent: (canvasId, nodeId) => - getCanvasStore(canvasId).readNode(nodeId)?.content ?? null, + readNodeContent: async (canvasId, nodeId) => + (await readCanvasNode(canvasId, nodeId))?.content ?? null, }; /** @@ -74,8 +79,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 +101,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 +153,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..8fc5e4369 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -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', @@ -173,11 +173,11 @@ describe('AgentThreadService', () => { const harness = createHarness({ target: null, persistedBinding: binding }); expect( - harness.service.resolveExternalTarget('canvas-a', 'thread-a'), + await harness.service.resolveExternalTarget('canvas-a', 'thread-a'), ).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', @@ -187,7 +187,7 @@ describe('AgentThreadService', () => { }); expect( - harness.service.resolveExternalTarget('canvas-a', 'thread-a'), + await harness.service.resolveExternalTarget('canvas-a', 'thread-a'), ).toEqual({ binding: TARGET.agentBinding, fixedTarget: TARGET }); }); diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 1ab52dab2..e1896e340 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 | FixedAgentNodeTarget | null; 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) + ? await 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..0c01967e8 100644 --- a/apps/server/src/modules/agent/conversation/envelope.ts +++ b/apps/server/src/modules/agent/conversation/envelope.ts @@ -24,7 +24,7 @@ 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 { readCanvasNode, readCanvasNodes } from '../../canvas/space-read.js'; import { isUserInvokableSkill } from '../skills.route.js'; import type { NodeNeighbourhoodContext } from '../../canvas/node-neighbourhood.js'; @@ -205,18 +205,23 @@ function collectSketchStrokeSubsets( * store is unavailable, so refs fall back to bare `{ id, type, label?, * filename }` (no preview). */ -function collectSelectedNodeRefs( +async function collectSelectedNodeRefs( nodes: WireSelectionNode[], canvasId: string | null, -): AgentNodePreview[] { - let store: ReturnType | null = null; - if (canvasId) { - try { - store = getCanvasStore(canvasId); - } catch { - store = null; +): Promise { + const selectedIds: string[] = []; + const collectIds = (list: WireSelectionNode[]): void => { + for (const n of list) { + selectedIds.push(n.id); + if (n.children) collectIds(n.children); } - } + }; + collectIds(nodes); + // A selection is a handful of nodes; reading the whole Space to describe + // them would make an unrelated node somewhere else cost this request. + const records = canvasId + ? await readCanvasNodes(canvasId, selectedIds) + : undefined; const refs: AgentNodePreview[] = []; const walk = (list: WireSelectionNode[]) => { for (const n of list) { @@ -226,7 +231,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 +238,7 @@ function collectSelectedNodeRefs( ...(n.src !== undefined ? { src: n.src } : {}), }, 'preview', + records?.get(n.id), ), ); if (n.children) walk(n.children); @@ -408,10 +413,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< + const meta = (await readCanvasNode(canvasId, anchorNodeId)) as Record< string, unknown > | null; @@ -463,7 +468,7 @@ export async function buildChatEnvelope( focus: { selection: { refs: selectedNodes - ? collectSelectedNodeRefs(selectedNodes, canvasId) + ? await collectSelectedNodeRefs(selectedNodes, canvasId) : [], selectedIds: selectedNodes ? collectSelectedNodeIds(selectedNodes) : [], imageAttachments: dedupedImageAttachments, diff --git a/apps/server/src/modules/agent/memory/trigger.ts b/apps/server/src/modules/agent/memory/trigger.ts index 6367f1c58..3eaf49a6e 100644 --- a/apps/server/src/modules/agent/memory/trigger.ts +++ b/apps/server/src/modules/agent/memory/trigger.ts @@ -26,7 +26,7 @@ import { existsSync } from 'node:fs'; import { atomicWriteJson, mkdirp, readJson } from '../../../utils/fs.js'; import { createKeyedMutex } from '../../../utils/keyed-mutex.js'; -import { spaceDirectory } from '../../storage/index.js'; +import { diskSpaceTree } from '../../storage/index.js'; import { memoryStatePath, canvasMemoryDir } from '../../workspace/paths.js'; /** Op-count threshold that triggers a memory analysis pass. */ @@ -83,7 +83,7 @@ export function writeMemoryState(canvasId: string, state: MemoryState): void { // file. Same hazard for any in-flight memory worker that calls // `markAnalyzed` post-delete. Skip the write when the canvas root // is gone; losing one bookkeeping write is harmless. - if (!existsSync(spaceDirectory(canvasId))) return; + if (!existsSync(diskSpaceTree(canvasId).directory())) return; mkdirp(canvasMemoryDir(canvasId)); atomicWriteJson(memoryStatePath(canvasId), state); } 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..59a019b13 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 { readCanvasNode } from '../../../canvas/space-read.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 readCanvasNode(canvasId, nodeId); if (!nc) return undefined; const rev = nodeRevisionOf({ content: nc.content, @@ -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..d822dc497 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,8 @@ 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 { readCanvas } from '../../../canvas/space-read.js'; +import { diskSpaceTree } from '../../../storage/index.js'; // ─── Always-skipped directory names ───────────────────────────────────────── @@ -143,7 +144,7 @@ export function safeResolve(canvasId: string, rel: string): string { ) { throw new Error(`Invalid canvasId: ${canvasId}`); } - const root = spaceDirectory(canvasId); + const root = diskSpaceTree(canvasId).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)); @@ -290,29 +291,23 @@ export interface NodeMeta { } /** - * Lazy single-Space node lookup. Reads `space.json` at most once, - * returning a closure that maps a canvas-relative path to its - * `NodeMeta` if it matches `nodes/.md` and can be resolved via - * frontmatter `id:` plus `space.json` metadata. - * Returns `null` otherwise. + * Single-Space node lookup: a canvas-relative path to its {@link NodeMeta}, + * or `null` when the path is not a node file. + * + * The Space record is read up front — one small read that gives every node's + * type and label. Building the path index is deferred to the first lookup + * that could use one, because that step opens and parses every file under + * `nodes/` and most searches never touch a node file at all: a grep for a + * string that lives in `.memory/` or an uploaded document would otherwise pay + * for the whole Space before matching anything. */ -export function makeNodeLookup( +export async function makeNodeLookup( canvasId: string, -): (canvasRelPath: string) => NodeMeta | 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) { +): Promise<(canvasRelPath: string) => NodeMeta | null> { + const byId = new Map(); + try { + const file = await readCanvas(canvasId); + if (file !== null) { const nodes = (file.state.nodes ?? []) as Array>; for (const n of nodes) { const id = n.id; @@ -323,33 +318,24 @@ export function makeNodeLookup( byId.set(id, { nodeId: id, nodeType, label }); } } + } catch { + // File enrichment is best-effort; the path result remains usable. + } - let nodesRoot: string; - try { - nodesRoot = safeResolve(canvasId, 'nodes'); - } catch { - cache = byPath; - return byPath; - } - - let nodesStat; - try { - nodesStat = statSync(nodesRoot); - } catch { - cache = byPath; - return byPath; - } - if (!nodesStat.isDirectory()) { - cache = byPath; - return byPath; - } + let byPath: Map | null = null; + const indexByPath = (): Map => { + if (byPath) return byPath; + const index = new Map(); + byPath = index; + let nodesRoot: string; let entries: Dirent[]; try { + nodesRoot = safeResolve(canvasId, 'nodes'); + if (!statSync(nodesRoot).isDirectory()) return index; entries = readdirSync(nodesRoot, { withFileTypes: true }); } catch { - cache = byPath; - return byPath; + return index; } for (const ent of entries) { @@ -370,16 +356,14 @@ export function makeNodeLookup( nodeType: undefined, label: undefined, }; - byPath.set(`nodes/${ent.name}`, metaFromCanvas); + index.set(`nodes/${ent.name}`, metaFromCanvas); } - - cache = byPath; - return byPath; + return index; }; return (canvasRelPath) => { const normalized = canvasRelPath.replace(/^\.\//, ''); if (!CANVAS_NODE_RE.test(normalized)) return null; - return ensure().get(normalized) ?? null; + return indexByPath().get(normalized) ?? null; }; } 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/canvas/canvas-command-router.test.ts b/apps/server/src/modules/canvas/canvas-command-router.test.ts index 5df34ef2b..7b20588c9 100644 --- a/apps/server/src/modules/canvas/canvas-command-router.test.ts +++ b/apps/server/src/modules/canvas/canvas-command-router.test.ts @@ -676,13 +676,13 @@ describe('workspace canvas command routing', () => { ? { ...node, type: 'note' } : node, ); - expect(() => + await expect( assertWorldPortalTopologyAllowed( 'canvas-world', brokenTopology, convertedDescendant, ), - ).toThrow('A node reference cannot change node type'); + ).rejects.toThrow('A node reference cannot change node type'); const output = await executeCanvasCommandsOnHost({ canvasId: 'canvas-world', @@ -799,13 +799,13 @@ describe('workspace canvas command routing', () => { expect( directlyLocked.find((node) => node.id === nodeRef.id)?.data, ).toMatchObject({ locked: true }); - expect(() => + await expect( assertWorldPortalTopologyAllowed( 'canvas-world', directlyLocked, directlyLocked, ), - ).not.toThrow(); + ).resolves.toBeUndefined(); await executeCanvasCommandsOnHost({ canvasId: 'canvas-world', @@ -828,13 +828,13 @@ describe('workspace canvas command routing', () => { expect( portalLocked.find((node) => node.id === nodeRef.id)?.data, ).toMatchObject({ __dragDisabledByFrameLock: true }); - expect(() => + await expect( assertWorldPortalTopologyAllowed( 'canvas-world', portalLocked, portalLocked, ), - ).not.toThrow(); + ).resolves.toBeUndefined(); await executeCanvasCommandsOnHost({ canvasId: 'canvas-world', @@ -887,13 +887,13 @@ describe('workspace canvas command routing', () => { | undefined; if (!copiedTarget || !target) throw new Error('Missing nodeRef target'); target.label = 'Copied source label'; - expect(() => + await expect( assertWorldPortalTopologyAllowed( 'canvas-world', styled ?? [], copiedTarget, ), - ).toThrow('contains unsupported source-owned data'); + ).rejects.toThrow('contains unsupported source-owned data'); await expect( executeCanvasCommandsOnHost({ @@ -932,9 +932,9 @@ describe('workspace canvas command routing', () => { const previous = getCanvasStore('canvas-world').read()?.state.nodes; if (!previous) throw new Error('Missing World state'); const canonical = structuredClone(previous); - expect(() => + await expect( assertWorldPortalTopologyAllowed('canvas-world', previous, canonical), - ).not.toThrow(); + ).resolves.toBeUndefined(); const resized = structuredClone(previous) as Array<{ type?: string; @@ -943,20 +943,22 @@ describe('workspace canvas command routing', () => { const portal = resized.find((node) => node.type === 'canvasRef'); if (!portal?.style) throw new Error('Missing Portal'); portal.style.width = (portal.style.width ?? 0) + 100; - expect(() => + await expect( assertWorldPortalTopologyAllowed('canvas-world', previous, resized), - ).toThrow(WorldPortalMutationError); + ).rejects.toThrow(WorldPortalMutationError); const withoutNodeRef = ( structuredClone(previous) as Array<{ type?: string }> ).filter((node) => node.type !== 'nodeRef'); - expect(() => + await expect( assertWorldPortalTopologyAllowed( 'canvas-world', previous, withoutNodeRef, ), - ).toThrow('Node references must be removed with SET_PORTAL_NODE_PINS'); + ).rejects.toThrow( + 'Node references must be removed with SET_PORTAL_NODE_PINS', + ); }); it('allows moving but rejects manually resizing a frameRef', async () => { diff --git a/apps/server/src/modules/canvas/canvas-command-router.ts b/apps/server/src/modules/canvas/canvas-command-router.ts index fc38724ed..92793ddf5 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, requireWorldCanvasId } from '../storage/index.js'; type PortalCommand = Extract; const withPortalRoutingMutex = createKeyedMutex(); @@ -210,7 +206,9 @@ async function ensureCanonicalPortals( worldCanvasId: string, commands: readonly PortalCommand[], ): Promise { - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const world = (await getStructuredStore() + .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 +247,17 @@ async function executeCanvasCommandsOnHostInternal( assertConsistentDesiredStates(portalCommands); - const worldCanvasId = requireWorldCanvasId(); + const worldCanvasId = await requireWorldCanvasId(); if (!routingLockHeld) { return withPortalRoutingMutex(worldCanvasId, () => executeCanvasCommandsOnHostInternal(input, true), ); } await ensureCanonicalPortals(worldCanvasId, portalCommands); - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const structured = getStructuredStore(); + const world = (await structured + .space(worldCanvasId) + .read()) as StoredCanvas | null; if (!world || !Array.isArray(world.state.nodes)) { throw new CanvasCommandRoutingError('World Canvas is unavailable'); } @@ -335,17 +336,20 @@ async function executeCanvasCommandsOnHostInternal( } const liveCanvasIds = new Set( - listCanvasDirEntries().map((entry) => entry.id), + (await structured.spaces().list()).map((entry) => entry.canvasId), ); - const sourceStates = new Map(); - const readSource = (canvasId: string): SourceState | null => { + const sourceStates = new Map>(); + const readSource = (canvasId: string): Promise => { if (!sourceStates.has(canvasId)) { sourceStates.set( canvasId, - sourceStateOf(getCanvasStore(canvasId).read() as StoredCanvas | null), + structured + .space(canvasId) + .read() + .then((canvas) => sourceStateOf(canvas as StoredCanvas | null)), ); } - return sourceStates.get(canvasId) ?? null; + return sourceStates.get(canvasId) ?? Promise.resolve(null); }; const requested = new Map(); @@ -356,7 +360,7 @@ async function executeCanvasCommandsOnHostInternal( throw new MissingWorldPortalError(update.sourceCanvasId); } const source = liveCanvasIds.has(update.sourceCanvasId) - ? readSource(update.sourceCanvasId) + ? await readSource(update.sourceCanvasId) : null; for (const sourceNodeId of update.sourceNodeIds) { requested.set( @@ -398,7 +402,7 @@ async function executeCanvasCommandsOnHostInternal( const sourcePositions: PreparedPortalSourcePosition[] = []; for (const target of positionTargets.values()) { - const position = readSource(target.canvasId)?.absolutePosition( + const position = (await readSource(target.canvasId))?.absolutePosition( target.nodeId, ); if (!position) continue; @@ -433,6 +437,18 @@ async function executeCanvasCommandsOnHostInternal( } }; + const resolvedSources = new Map(); + for (const command of portalCommands) { + for (const update of command.updates) { + if (!resolvedSources.has(update.sourceCanvasId)) { + resolvedSources.set( + update.sourceCanvasId, + await readSource(update.sourceCanvasId), + ); + } + } + } + const commands = portalCommands.map((command): CanvasCommand => { const pins: PreparedPortalNodePin[] = []; const seen = new Set(); @@ -446,7 +462,7 @@ async function executeCanvasCommandsOnHostInternal( throw new MissingWorldPortalError(update.sourceCanvasId); } const desiredPinned = requested.get(key) ?? update.pinned; - const source = readSource(update.sourceCanvasId); + const source = resolvedSources.get(update.sourceCanvasId) ?? null; const sourceNode = source ? sourceNodeById(source, sourceNodeId) : null; const existing = preparedReferenceByTarget.get(key); if (!desiredPinned || !source || !sourceNode) { diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts index 30461bec6..fffe47e8f 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -65,12 +65,10 @@ import { import { getLogger } from '../../utils/logger.js'; import { canvasBlobs, - getCanvasStore, getStructuredStore, withCanvasMutex, type BlobScope, type CanvasFile, - type CanvasStore, type DeltaLogEntry, type NodeContent, type SpaceNodeMutation, @@ -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) ?? null; if (!content) return { ...node }; const data: Record = { ...(node.data ?? {}) }; @@ -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,9 +693,8 @@ export async function executeOnServer( } return await withCanvasMutex(canvasId, async () => { - const store = getCanvasStore(canvasId); const handle = getStructuredStore().space(canvasId); - const canvas = store.read(); + const canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); const fromVersion = canvas.version; @@ -714,13 +702,19 @@ export async function executeOnServer( // 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 nodeRecords = new Map( + [...(await handle.nodes.list())].map(([nodeId, snapshot]) => [ + nodeId, + snapshot.record, + ]), + ); const prestateNodes = hydrateNodes( - store, + nodeRecords, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; - assertWorldPortalMutationsAllowed( + await assertWorldPortalMutationsAllowed( canvasId, commands, prestateNodes, @@ -824,7 +818,7 @@ export async function executeOnServer( const sharedOut = applySharedPostEffectsFromWriteResult(writeResult); const finalNodes = writeResult.nodes; const finalEdges = sharedOut.edges; - assertWorldPortalResultAllowed(canvasId, prestateNodes, finalNodes); + await assertWorldPortalResultAllowed(canvasId, prestateNodes, finalNodes); const deltas = diffCanvasState( { nodes: prestateNodes, edges: prestateEdges }, @@ -1123,14 +1117,19 @@ 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 canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); const fromVersion = canvas.version; + const nodeRecords = new Map( + [...(await handle.nodes.list())].map(([nodeId, snapshot]) => [ + nodeId, + snapshot.record, + ]), + ); const prestateNodes = hydrateNodes( - store, + nodeRecords, 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..be950b267 100644 --- a/apps/server/src/modules/canvas/canvas-search.test.ts +++ b/apps/server/src/modules/canvas/canvas-search.test.ts @@ -7,8 +7,8 @@ * 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 - * `streamAllNodes` walks an in-memory snapshot. Production reads sidecars + * — all lives here and is exercised against a fake `SpaceHandle` whose + * node stream walks an in-memory snapshot. Production reads records * off disk, but the scanner takes a callback so the two are wire-compatible. */ @@ -24,7 +24,11 @@ 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, + SpaceHandle, +} from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; import type { CanvasSearchEvent, CanvasSearchRequest } from '@huabu/shared'; @@ -76,16 +80,16 @@ function mkEdge( } /** - * Build a duck-typed `CanvasStore` that satisfies just the two methods + * Build a duck-typed `SpaceHandle` that satisfies just the reads * `searchCanvas` actually reads from: `read()` (for the static node + edge - * shape) and `streamAllNodes()` (for the sidecar bodies). The rest of the + * shape) and `nodes.stream()` (for the node records). The rest of the * store surface is irrelevant here, so we cast through `unknown`. */ function makeFakeStore(opts: { nodes: readonly SearchableNode[]; contents: readonly NodeContent[]; edges?: readonly SearchableEdge[]; -}): CanvasStore { +}): SpaceHandle { const stateNodes = opts.nodes.map((n) => ({ id: n.id, type: n.type, @@ -100,21 +104,24 @@ 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: { + stream: async ( + onNode: (id: string, snapshot: NodeSnapshot) => void, + signal?: { readonly aborted: boolean }, + ): Promise> => { + const map = new Map(); + for (const content of opts.contents) { + if (signal?.aborted) return map; + const snapshot = { record: content, revision: content.nodeId }; + map.set(content.nodeId, snapshot); + onNode(content.nodeId, snapshot); + } + return map; + }, }, }; - return fake as unknown as CanvasStore; + return fake as unknown as SpaceHandle; } async function collect( diff --git a/apps/server/src/modules/canvas/canvas-search.ts b/apps/server/src/modules/canvas/canvas-search.ts index 17e0754d6..39f073b70 100644 --- a/apps/server/src/modules/canvas/canvas-search.ts +++ b/apps/server/src/modules/canvas/canvas-search.ts @@ -6,11 +6,8 @@ * * Two-tier scan: * 1. **Metadata tier** (label / summary / keywords) — emitted as each - * sidecar lands via `CanvasStore.streamAllNodes`. Matches start - * flowing to the client after the very first `.md` read resolves, - * not after the whole directory has been pulled into memory, so - * perceived latency is bounded by the slowest single file rather - * than the full scan. + * node record lands via `SpaceNodes.stream`. Matches start flowing + * before the whole Space has been pulled into memory. * 2. **Content tier** (markdown body) — scans the same `NodeContent` * bodies already cached by step 1. No additional disk reads. * @@ -18,13 +15,12 @@ * Search is low-frequency. The OS page cache handles repeated reads * transparently, and adding our own cache would cost permanent RAM * plus an invalidation surface across every sidecar write path. If a - * single canvas grows past ~30 MB of sidecar text, the right next - * step is swapping the scan for spawned `ripgrep`, not in-process - * caching. See discussion in #search-architecture for the call. + * single Space grows past ~30 MB of text, the right next step is a + * backend-native index, not another application cache. * * Cancellation: * The caller passes a `signal` (AbortSignal-like — we only need - * `.aborted` polling). Workers inside `streamAllNodes` short-circuit + * `.aborted` polling). Workers inside `SpaceNodes.stream` short-circuit * when the signal aborts, and the content-tier loop checks before * each node so a superseded keystroke doesn't waste CPU. * @@ -46,7 +42,8 @@ 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 } from './persistence-types.js'; +import type { SpaceHandle } from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; /** Window of characters shown around each match in `snippet`. */ @@ -460,7 +457,7 @@ function stringArrayFrontmatter( } /** - * Adapter: collect persisted nodes from a `CanvasStore.read()` payload. + * Adapter: collect persisted nodes from a Space record payload. * Lives here so the route layer can stay thin and tests can synthesise * canvas state without touching disk. */ @@ -494,7 +491,7 @@ export function extractSearchableNodes(state: unknown): SearchableNode[] { } /** - * Adapter: collect persisted edges from a `CanvasStore.read()` payload. + * Adapter: collect persisted edges from a Space record payload. * Reads only what the scanner needs (`id`, endpoints, `label`); other * `EdgeStyle` fields (lineStyle, stroke, …) are intentionally * dropped so the in-memory footprint stays bounded. @@ -531,20 +528,20 @@ export function extractSearchableEdges(state: unknown): SearchableEdge[] { } /** - * Drive a search directly off a {@link CanvasStore}. + * Drive a search directly off a backend-neutral {@link SpaceHandle}. * - * Streams meta-tier matches as each sidecar lands (no `await`-all + * Streams meta-tier matches as each node 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 storage reads. */ export async function searchCanvas( - store: CanvasStore, + space: SpaceHandle, request: CanvasSearchRequest, emit: (event: CanvasSearchEvent) => void, signal?: AbortSignal, ): Promise { - const file = store.read(); + const file = await space.read(); if (!file) { emit({ type: 'error', message: 'Canvas not found' }); return; @@ -583,7 +580,7 @@ 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) => { + const snapshots = await space.nodes.stream((id, snapshot) => { if (signal?.aborted) return; if (!wantsMeta) return; if (totalEmitted >= limit) { @@ -592,13 +589,16 @@ export async function searchCanvas( } 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. + // `nodeId` / `nodeTypes` — the backend scan is unfiltered, but those + // records contribute nothing here. if (!node) return; - scanNodeMeta(node, content, fields, needleLower, needleLen, (m) => + scanNodeMeta(node, snapshot.record, fields, needleLower, needleLen, (m) => tryEmitMatch('meta', m), ); }, signal); + const contentByNodeId = new Map( + [...snapshots].map(([nodeId, snapshot]) => [nodeId, snapshot.record]), + ); if (signal?.aborted) return; @@ -677,7 +677,7 @@ export async function searchCanvas( const label = content?.label ?? null; scanNodeConversation( node, - store.canvasId, + space.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..ad2b2cce7 100644 --- a/apps/server/src/modules/canvas/canvas-spatial.ts +++ b/apps/server/src/modules/canvas/canvas-spatial.ts @@ -36,8 +36,7 @@ * parent, visual style on `data.style`, edge endpoints + * `data.edgeStyle`, plus all derived spatial/topological metadata. * - When `includePreviews` is set, outline pulls the preview text - * via `CanvasStore.readNode` — the only place it crosses into the - * markdown side, kept gated behind an opt-in flag. + * from the already-read node records, kept gated behind an opt-in flag. */ import { @@ -53,10 +52,10 @@ import { } from '@huabu/shared/canvas-engine'; import { describeNode, nodeLabel, type NodeInput } from './node-prompt.js'; -import { getCanvasStore } from '../storage/index.js'; +import { readCanvas, readCanvasSnapshot } from './space-read.js'; +import type { CanvasFile } from './persistence-types.js'; import type { AgentNodeOutline } from '../agent/node-ref.js'; -import type { CanvasFile } from '../storage/canvas-store.js'; import type { CanvasNodeType, CardinalDirection, @@ -282,13 +281,13 @@ 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(); - if (!canvas) return null; +): Promise { + const snapshot = await readCanvasSnapshot(canvasId); + if (!snapshot) return null; + const { canvas, nodes: nodeRecords } = snapshot; const bundle = buildSpatialBundle(canvas); const summary = buildSpatialSummary(bundle.spatialNodes, bundle.edges); @@ -314,7 +313,7 @@ export function buildCanvasOutline( const labelMemo = new Map(); const memoLabel = (id: string): string | undefined => { if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); + const l = nodeLabel(nodeRecords.get(id)); labelMemo.set(id, l); return l; }; @@ -324,12 +323,12 @@ export function buildCanvasOutline( const parentLabel = s.parentId ? memoLabel(s.parentId) : undefined; const style = opts.includeStyle ? readVisualStyle(raw) : undefined; const out: CanvasOutlineNode = describeNode( - store, { ...spatialNodeInput(s, raw, parentLabel), ...(style ? { style } : {}), }, 'outline', + nodeRecords.get(s.id), ); // The shared builder attaches `summary` (authored abstract) and // `preview` (raw body excerpt) from the sidecar; both are text scan @@ -449,13 +448,13 @@ 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(); - if (!canvas) return { error: `Canvas ${canvasId} not found` }; +): Promise { + const snapshot = await readCanvasSnapshot(canvasId); + if (!snapshot) return { error: `Canvas ${canvasId} not found` }; + const { canvas, nodes: nodeRecords } = snapshot; const bundle = buildSpatialBundle(canvas); @@ -464,7 +463,7 @@ export function inspectNodes( const labelMemo = new Map(); const memoLabel = (id: string): string | undefined => { if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); + const l = nodeLabel(nodeRecords.get(id)); labelMemo.set(id, l); return l; }; @@ -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', + nodeRecords.get(s.id), ); // 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 readCanvas(canvasId); if (!canvas) return { error: `Canvas ${canvasId} not found` }; const bundle = buildSpatialBundle(canvas); diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index b6ffedde6..97cd4ebf9 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 } from 'node:fs'; +import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -33,6 +33,11 @@ import { import { CanvasNotFoundError, applyDeltasOnServer } from './canvas-executor.js'; import { searchCanvas } from './canvas-search.js'; import { publishCanvasUpdate } from './canvas-sync.js'; +import { + readCanvas, + readCanvasNode, + readCanvasSnapshot, +} from './space-read.js'; import { assertWorldPortalTopologyAllowed, WorldPortalMutationError, @@ -43,31 +48,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, - refreshCanvasDirIndex, - registerCanvasDir, - suggestCanvasDir, -} from '../storage/canvas-dirs.js'; import { canvasBlobs, createSpace, deleteSpace, - getCanvasStore, + diskSpaceTree, + stageDiskSpaceImport, getStructuredStore, - spaceDirectory, + isWorldCanvasId, type CanvasFile, + type NodeContent, + type NodeReadWarning, 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, @@ -114,6 +112,10 @@ interface NodeLike { [key: string]: unknown; } +/** Stable record names in the portable `.huabu.zip` bundle format. */ +const SPACE_BUNDLE_RECORD_FILENAME = 'space.json'; +const LEGACY_CANVAS_BUNDLE_RECORD_FILENAME = 'canvas.json'; + function nowMs(): number { return Date.now(); } @@ -347,20 +349,18 @@ async function singleArtifactProbe( * {@link hydrateNodeContent}; also used by the per-node GET endpoint so * batch and single-node hydration stay in lock-step. * - * `preloaded` lets the batch path inject content from a one-pass - * directory scan (see {@link CanvasStore.readAllNodes}) so we don't - * re-read every `.md` file per node. Pass `undefined` to fall back to - * the targeted single-node `store.readNode(nodeId)` lookup; pass - * `null` to indicate the batch scan ran but found no sidecar. + * `nodeContent` comes from the request's structured read. Batch hydration + * supplies the result of one `SpaceNodes.list()` call; the single-node route + * supplies one `SpaceNodes.read()` result. * * Returns the original `node` reference when nothing was mutated so * callers can rely on identity-based diffing. */ function hydrateOneNode( - store: CanvasStore, node: NodeLike, artifactExists: (key: string) => boolean, - preloaded?: NodeContent | null, + nodeContent: NodeContent | null, + warnings: readonly NodeReadWarning[] = [], ): NodeLike { const nodeId = typeof node.id === 'string' ? node.id : ''; if (!nodeId) return node; @@ -375,17 +375,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; @@ -404,12 +393,14 @@ function hydrateOneNode( // claims this nodeId. Unlike a write (which hard-fails), a read stays // best-effort — the index keeps the last-scanned file so the node still // renders — but the client can flag it so the user resolves the - // 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)) { + // duplicate. The adapter attached this warning to the same node snapshot, + // so checking it adds no second storage read. + const duplicate = warnings.find( + (warning) => warning.kind === 'duplicate-record', + ); + if (duplicate) { data['contentDuplicate'] = true; - data['duplicateFiles'] = store.duplicateNodeFiles(nodeId); + data['duplicateFiles'] = duplicate.names; } else { if ('contentDuplicate' in data) { delete data['contentDuplicate']; @@ -508,13 +499,14 @@ function hydrateOneNode( * load on cold cache. */ async function hydrateNodeContent( - store: CanvasStore, + canvasId: string, nodes: NodeLike[], + contentByNodeId: ReadonlyMap, + warningsByNodeId: ReadonlyMap, ): 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 referenced = new Set(); for (const node of nodes) { const nodeType = typeof node.type === 'string' ? node.type : ''; @@ -527,16 +519,16 @@ async function hydrateNodeContent( const present = referenced.size === 0 ? new Set() - : await canvasBlobs(store.canvasId).hasMany([...referenced]); + : await canvasBlobs(canvasId).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, + warningsByNodeId.get(nodeId) ?? [], ); }); } @@ -882,20 +874,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 = getStructuredStore().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,12 +889,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { let nodeType = stateNode && typeof stateNode.type === 'string' ? stateNode.type : ''; - let existing: NodeContent | null = null; - try { - existing = store.readNode(nodeId); - } catch { - existing = null; - } + const existingSnapshot = await handle.nodes.read(nodeId); + const existing = existingSnapshot?.record ?? null; if (!nodeType && existing) { nodeType = existing.type; } @@ -934,13 +914,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, + existingSnapshot?.warnings, ); const data = (hydrated.data ?? {}) as Record; @@ -1010,9 +991,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 readCanvasNode(canvasId, nodeId)) + ) { return reply.send({ nodeId, success: false, @@ -1095,20 +1077,24 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { Reply: ApiResult; }>('/:canvasId', async function (request, reply) { const { canvasId } = request.params; - if (isWorldCanvasId(canvasId)) { + if (await isWorldCanvasId(canvasId)) { await reconcileWorldPortals(); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); - - if (!canvas) { + const snapshot = await readCanvasSnapshot(canvasId); + if (!snapshot) { return reply.code(404).send({ message: 'Canvas not found' }); } + const { canvas } = snapshot; // 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( + canvasId, + nodes, + snapshot.nodes, + snapshot.nodeWarnings, + ); return reply.send({ canvasId: canvas.canvasId, @@ -1169,7 +1155,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { } try { - assertWorldPortalTopologyAllowed( + await assertWorldPortalTopologyAllowed( canvasId, (existing?.state.nodes ?? []) as NodeLike[], incomingState.nodes ?? [], @@ -1563,18 +1549,18 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { * user can resolve a duplicate-markdown collision by hand (keep one * file, delete the rest). Desktop-first: the server runs on the same * machine as the UI, so it owns the only reliable filesystem path. - * The folder is sandboxed to the workspace via {@link nodesDir}. + * The folder is sandboxed to the workspace by the materialization + * capability, which validates the id before resolving a path. */ fastify.post<{ Params: { canvasId: string }; Reply: ApiResult; }>('/:canvasId/reveal-nodes', async function (request, reply) { const { canvasId } = request.params; - const store = getCanvasStore(canvasId); - if (!store.read()) { + if (!(await readCanvas(canvasId))) { return reply.code(404).send({ message: 'Canvas not found' }); } - const dir = nodesDir(canvasId); + const dir = diskSpaceTree(canvasId).nodesDirectory(); if (!existsSync(dir)) { return reply.code(404).send({ message: 'Nodes folder not found' }); } @@ -1604,13 +1590,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { } const includeHistory = parsedQuery.data.includeHistory !== 'false'; - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const canvas = await readCanvas(canvasId); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } - const canvasDir = spaceDirectory(canvasId); + const canvasDir = diskSpaceTree(canvasId).directory(); if (!existsSync(canvasDir)) { return reply.code(404).send({ message: 'Canvas directory not found' }); } @@ -1671,14 +1656,8 @@ 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}`, - ); - let stagingCleanedUp = false; + const importStaging = await stageDiskSpaceImport(targetCanvasId); + const stagingDir = importStaging.directory; try { await new Promise((resolve, reject) => { const ws = createWriteStream(tmpZip); @@ -1702,8 +1681,6 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }); } - mkdirSync(stagingDir, { recursive: true }); - type ImportManifest = { version?: string; sourceCanvasId?: string; @@ -1746,16 +1723,21 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // 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 stagedJsonPath = path.join( + stagingDir, + SPACE_BUNDLE_RECORD_FILENAME, + ); + const legacyJsonPath = path.join( + stagingDir, + LEGACY_CANVAS_BUNDLE_RECORD_FILENAME, + ); const sourceJsonPath = existsSync(stagedJsonPath) ? stagedJsonPath : existsSync(legacyJsonPath) ? legacyJsonPath : null; if (!sourceJsonPath) { - await rm(stagingDir, { recursive: true, force: true }); - stagingCleanedUp = true; + await importStaging.discard(); return reply.code(400).send({ message: 'Invalid bundle: missing space.json', }); @@ -1766,41 +1748,18 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { 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 = { ...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(); + await importStaging.publish(remapped); const response: ImportCanvasResponse = { canvasId: targetCanvasId, @@ -1811,11 +1770,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { return reply.code(500).send({ message: 'Failed to import canvas' }); } finally { void unlink(tmpZip).catch(() => {}); - if (!stagingCleanedUp && existsSync(stagingDir)) { - await rm(stagingDir, { recursive: true, force: true }).catch( - () => {}, - ); - } + await importStaging.discard().catch(() => {}); } }, ); @@ -1840,8 +1795,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const space = getStructuredStore().space(canvasId); + const canvas = await space.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } @@ -1880,7 +1835,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { request.raw.on('close', onClose); try { - await searchCanvas(store, parsed.data, writeEvent, abort.signal); + await searchCanvas(space, 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..341bceb7b 100644 --- a/apps/server/src/modules/canvas/external-watcher.test.ts +++ b/apps/server/src/modules/canvas/external-watcher.test.ts @@ -69,7 +69,9 @@ vi.mock('../storage/canvas-dirs.js', () => ({ })); const canvasStore = vi.hoisted(() => ({ - read: vi.fn(() => ({ state: { nodes: [] } })), + read: vi.fn<() => { state: { nodes: never[] } } | null>(() => ({ + state: { nodes: [] }, + })), })); // The facade is stubbed for the store, but the handle helpers must stay the @@ -79,7 +81,23 @@ const canvasStore = vi.hoisted(() => ({ vi.mock('../storage/index.js', async () => { const handles = await import('../storage/backends/disk/space-dir-handles.js'); return { - getCanvasStore: () => canvasStore, + getStructuredStore: () => ({ + space: () => ({ read: async () => canvasStore.read() }), + }), + diskSpaceTree: (canvasId: string) => { + const directoryName = + canvasDirs.list().find((entry) => entry.id === canvasId)?.filename ?? + canvasId; + const directory = `/ws/${directoryName}`; + return { + canvasId, + directory: () => directory, + nodesDirectory: () => `${directory}/nodes`, + nodeIdForPath: async () => null, + registerHandleOwner: (owner: { release(): void; reacquire(): void }) => + handles.registerSpaceDirHandleOwner(canvasId, owner), + }; + }, registerSpaceDirHandleOwner: handles.registerSpaceDirHandleOwner, withSpaceDirHandlesReleased: handles.withSpaceDirHandlesReleased, }; @@ -105,7 +123,7 @@ import { openExternalNoteSession, resetExternalNoteSessions, } from './external-watcher.js'; -import { withSpaceDirHandlesReleased } from '../storage/index.js'; +import { withSpaceDirHandlesReleased } from '../storage/backends/disk/space-dir-handles.js'; import type { ExternalNoteEvent } from '@huabu/shared'; @@ -252,7 +270,7 @@ describe('openExternalNoteSession', () => { (await opening).close(); }); - it('reads the Space topology at most once per lazy scan', async () => { + it('shares one lazy scan while refreshing topology per subscriber', async () => { canvasDirs.list.mockReturnValue([{ id: 'canvas-a', filename: 'canvas-a' }]); fileIO.readdir.mockResolvedValue([ { name: 'first.md', isFile: () => true }, @@ -266,9 +284,9 @@ describe('openExternalNoteSession', () => { expect( readPaths.filter((filePath) => filePath.endsWith('.md')), ).toHaveLength(2); - expect( - readPaths.filter((filePath) => filePath.endsWith('space.json')), - ).toHaveLength(1); + // One read supplies the shared scan and one refresh supplies each + // subscriber's snapshot against the current topology. + expect(canvasStore.read).toHaveBeenCalledTimes(3); expect(fileIO.readdir).toHaveBeenCalledTimes(1); expect(first.snapshot).toHaveLength(2); @@ -796,6 +814,7 @@ describe('Space-directory handle release', () => { await withSpaceDirHandlesReleased('canvas-a', async () => { canvasDirs.list.mockReturnValue([]); + canvasStore.read.mockReturnValue(null); }); expect(nativeWatchMock).not.toHaveBeenCalled(); diff --git a/apps/server/src/modules/canvas/external-watcher.ts b/apps/server/src/modules/canvas/external-watcher.ts index fc37894a7..fb0995e72 100644 --- a/apps/server/src/modules/canvas/external-watcher.ts +++ b/apps/server/src/modules/canvas/external-watcher.ts @@ -30,13 +30,11 @@ import { import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; +import { readCanvas } from './space-read.js'; 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 { registerSpaceDirHandleOwner } from '../storage/index.js'; -import { SPACE_JSON_FILENAME } from '../storage/paths.js'; -import { getWorkspacePath, isWorkspaceConfigured } from '../workspace.js'; +import { diskSpaceTree } from '../storage/index.js'; +import { isWorkspaceConfigured } from '../workspace.js'; import type { CanvasFile } from '../storage/index.js'; import type { ExternalNoteEvent, ExternalNoteItem } from '@huabu/shared'; @@ -113,11 +111,11 @@ function isSessionCurrent(session: ActiveSpaceWatch, stamp?: string): boolean { function nodesPathFor(canvasId: string): string | null { if (!isWorkspaceConfigured()) return null; - const entry = listAllCanvasDirEntries().find( - (candidate) => candidate.id === canvasId, - ); - if (!entry) return null; - return path.join(getWorkspacePath(), entry.filename, 'nodes'); + try { + return diskSpaceTree(canvasId).nodesDirectory(); + } catch { + return null; + } } function noteIdsFromCanvas(canvas: CanvasFile | null): Set { @@ -130,19 +128,15 @@ 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 readCanvas(canvasId)); } 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 +189,10 @@ function forgetItem(session: ActiveSpaceWatch, relativePath: string): void { emit(session, { type: 'removed', data: { relativePath } }); } -function snapshotOf(session: ActiveSpaceWatch): ExternalNoteItem[] { - const known = canvasNoteIds(session.canvasId); +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 +224,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); @@ -590,8 +586,16 @@ function destroySession(session: ActiveSpaceWatch): void { * a deleted Space drops its live state and tells subscribers it is now empty. * Idempotent, so it doubles as the handle-owner `reacquire` hook. */ -function resyncSession(session: ActiveSpaceWatch): void { +async function resyncSession(session: ActiveSpaceWatch): Promise { if (session.closed) return; + if (!(await readCanvas(session.canvasId))) { + disarmSessionWatcher(session); + session.nodesPath = ''; + session.pendingItems.clear(); + session.initialScan = null; + emit(session, { type: 'snapshot', data: { items: [] } }); + return; + } const nodesPath = nodesPathFor(session.canvasId); if (!nodesPath) { disarmSessionWatcher(session); @@ -674,7 +678,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 => { @@ -759,10 +763,12 @@ export async function openExternalNoteSession( sessions.set(canvasId, created); // Declare the handle so a server-owned rename/delete of this Space can // release it; `resyncSession` re-resolves the directory on re-acquire. - created.unregisterHandleOwner = registerSpaceDirHandleOwner(canvasId, { - release: () => disarmSessionWatcher(created), - reacquire: () => resyncSession(created), - }); + created.unregisterHandleOwner = diskSpaceTree(canvasId).registerHandleOwner( + { + release: () => disarmSessionWatcher(created), + reacquire: () => resyncSession(created), + }, + ); armSessionWatcher(created); } @@ -789,9 +795,10 @@ export async function openExternalNoteSession( // Registering the listener and reading the snapshot must stay in one // synchronous block so no event can slip between them. + const known = await canvasNoteIds(active.canvasId); 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..05258198a 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 { diskSpaceTree } from '../storage/index.js'; import type { FastifyPluginAsync } from 'fastify'; @@ -95,7 +95,10 @@ const externalRoutes: FastifyPluginAsync = async (fastify): Promise => { return reply.code(404).send({ message: 'External note not found' }); } - const abs = path.join(spaceDirectory(canvasId), item.relativePath); + const abs = path.join( + diskSpaceTree(canvasId).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..93cbae9bc 100644 --- a/apps/server/src/modules/canvas/import-node-src.test.ts +++ b/apps/server/src/modules/canvas/import-node-src.test.ts @@ -18,7 +18,7 @@ import { createCanvas } from '../storage/compatibility/canvas.js'; import { canvasBlobs, getCanvasStore, - spaceDirectory, + diskSpaceTree, } from '../storage/index.js'; import { setWorkspacePath } from '../workspace.js'; @@ -49,7 +49,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(diskSpaceTree(canvasId).directory(), '.upload'); mkdirSync(uploadDir, { recursive: true }); const abs = path.join(uploadDir, name); writeFileSync(abs, body); @@ -97,7 +97,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 +116,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… @@ -132,7 +131,6 @@ describe('importForeignNodeSources — web nodes', () => { 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 +146,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 +154,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 +169,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 +190,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 +198,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,7 +210,7 @@ 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$/); @@ -229,7 +224,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 +232,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 +241,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,7 +256,7 @@ 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(); @@ -273,8 +266,7 @@ describe('importForeignNodeSources — media nodes (regression)', () => { 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 = diskSpaceTree(canvasId).directory(); const artifactsDir = path.join(spaceDir, '.artifacts'); mkdirSync(artifactsDir, { recursive: true }); writeFileSync(path.join(artifactsDir, 'pic.png'), 'existing artifact'); @@ -298,7 +290,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..81dea1e5f 100644 --- a/apps/server/src/modules/canvas/import-node-src.ts +++ b/apps/server/src/modules/canvas/import-node-src.ts @@ -35,15 +35,14 @@ import { type CanvasNodeCreateInput, } from '@huabu/shared'; +import { readCanvas } from './space-read.js'; import { getLogger } from '../../utils/logger.js'; import { safeResolve, isArtifactsRel, toPhysicalRel, } from '../agent/tools/handlers/fs-sandbox.js'; -import { canvasBlobs, spaceDirectory } from '../storage/index.js'; - -import type { CanvasStore } from '../storage/index.js'; +import { canvasBlobs, diskSpaceTree } from '../storage/index.js'; const log = getLogger('canvas.import-node-src'); @@ -136,18 +135,17 @@ 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 { // Lazily built nodeId → nodeType map, needed only to gate MERGE_NODE_DATA // patches (CREATE_NODES carries `nodeType` inline). Node type is immutable, // so reading the pre-batch snapshot here is race-free. + const canvas = await readCanvas(canvasId); let typeById: Map | null = null; const nodeType = (nodeId: string): string => { if (!typeById) { typeById = new Map(); - const canvas = store.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, @@ -191,7 +188,6 @@ export async function importForeignNodeSources( const mode = srcNormalizeMode(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,10 @@ 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( + diskSpaceTree(canvasId).directory(), + absPath, + ); if (isArtifactsRel(resolvedPhysicalRel)) { const key = path.basename(absPath); const canonicalPath = safeResolve( @@ -295,12 +293,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 +307,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 canvasBlobs(canvasId).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 +332,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 +363,7 @@ async function downloadToArtifact( } const ext = pickDownloadExt(pathname, contentType); const key = `${createId('artifact')}${ext}`; - await canvasBlobs(store.canvasId).put(key, buffer); + await canvasBlobs(canvasId).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..d59f46225 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.ts @@ -41,12 +41,12 @@ import { import { buildSpatialBundle } from './canvas-spatial.js'; import { describeNode } from './node-prompt.js'; +import { readCanvas, readCanvasNodes } from './space-read.js'; import { escapeXmlAttr, renderNodes, } from '../agent/conversation/prompt/node-element.js'; import { buildAgentNodePreview } from '../agent/node-ref.js'; -import { getCanvasStore } from '../storage/index.js'; import type { AgentNodePreview } from '../agent/node-ref.js'; import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; @@ -72,17 +72,33 @@ import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; * `data.content` / `data.src` (text-on-canvas nodes whose body never * touches disk). Per-node disk reads are memoized. */ -export function getNodeNeighbourhood( +export async function getNodeNeighbourhood( canvasId: string, anchorNodeId: string, -): NodeNeighbourhoodContext | null { - const canvas = getCanvasStore(canvasId).read(); +): Promise { + const canvas = await readCanvas(canvasId); 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); + // Which nodes end up in the neighbourhood is the algorithm's decision, and + // it is a small bounded set — so let it decide first, without records, then + // read exactly those. The algorithm is pure geometry over an in-memory + // bundle; running it twice costs far less than reading a Space's worth of + // node records to describe a dozen of them. + const shape = buildNodeNeighbourhoodContext( + target, + bundle.spatialNodes, + bundle.edges, + ); + const nodeRecords = await readCanvasNodes( + canvasId, + shape.layers.flatMap((layer) => + layer.groups.flatMap((group) => group.nodes.map((node) => node.id)), + ), + ); + const cache = new Map(); // One assembler for every neighbour: the node carries whatever the spatial // bundle knows (id / type; its `data.label` is always empty), and @@ -93,9 +109,9 @@ export function getNodeNeighbourhood( const hit = cache.get(n.id); if (hit) return hit; const preview = describeNode( - store, { id: n.id, type: n.type, ...(n.label ? { label: n.label } : {}) }, 'preview', + nodeRecords.get(n.id), ); cache.set(n.id, preview); return preview; diff --git a/apps/server/src/modules/canvas/node-prompt.test.ts b/apps/server/src/modules/canvas/node-prompt.test.ts index e8f309348..1afdd07cf 100644 --- a/apps/server/src/modules/canvas/node-prompt.test.ts +++ b/apps/server/src/modules/canvas/node-prompt.test.ts @@ -15,33 +15,34 @@ 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 './persistence-types.js'; -/** Minimal stub: only `readNode` is exercised by this module. */ -function stubStore( +function stubMeta( nodes: Record | null>, -): CanvasStore { - 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; +): (id: string) => NodeContent | null { + return (id: string): NodeContent | null => { + const node = nodes[id]; + if (node === null || node === undefined) return null; + return { + nodeId: id, + type: 'note', + label: null, + content: '', + ...node, + } as NodeContent; + }; } describe('describeNode — preview level', () => { it('fills label + body from the sidecar when the caller has none', () => { - const store = stubStore({ + const meta = stubMeta({ 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', + meta('n1'), + ); expect(node.label).toBe('My Note'); // filename is derived from the (sidecar) label — the real on-disk path, @@ -53,19 +54,23 @@ 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 meta = stubMeta({ n1: { label: 'Sidecar Label', content: 'x' } }); const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Wire Label' }, 'preview', + meta('n1'), ); 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'); + const meta = stubMeta({ n1: { label: 'Empty' } }); // content '' by default + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + meta('n1'), + ); expect(node.label).toBe('Empty'); expect(node.rev).toBeUndefined(); expect(node.summary).toBeUndefined(); @@ -73,26 +78,33 @@ describe('describeNode — preview level', () => { }); it('emits summary and preview as INDEPENDENT fields', () => { - const store = stubStore({ + const meta = stubMeta({ 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', + meta('n1'), + ); 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({ + const meta = stubMeta({ 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', + meta('n1'), + ); 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', () => { const node = describeNode( - null, { id: 'n1', type: 'note', label: 'L', content: 'Body' }, 'preview', ); @@ -101,10 +113,8 @@ describe('describeNode — preview level', () => { 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('with meta=null, uses only the caller-authored fields', () => { const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Own' }, 'preview', null, @@ -116,11 +126,10 @@ describe('describeNode — preview level', () => { describe('describeNode — outline level', () => { it('layers spatial metadata on top of the sidecar-sourced fields', () => { - const store = stubStore({ + const meta = stubMeta({ n1: { label: 'Node', content: 'Body', summary: 'Sum' }, }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -130,6 +139,7 @@ describe('describeNode — outline level', () => { style: { color: 'red' }, }, 'outline', + meta('n1'), ); expect(node.position).toEqual({ x: 10, y: 20 }); expect(node.size).toEqual({ width: 30, height: 40 }); @@ -144,9 +154,8 @@ describe('describeNode — outline level', () => { }); it('carries an explicit absolutePosition distinct from parent-local position', () => { - const store = stubStore({ n1: { label: 'Node' } }); + const meta = stubMeta({ n1: { label: 'Node' } }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -156,6 +165,7 @@ describe('describeNode — outline level', () => { parentFrame: { id: 'f1', label: 'Frame' }, }, 'outline', + meta('n1'), ); expect(node.position).toEqual({ x: 50, y: 60 }); expect(node.absolutePosition).toEqual({ x: 1050, y: 560 }); @@ -164,14 +174,14 @@ 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'); + const meta = stubMeta({ n1: { label: 'Frame Title' } }); + expect(nodeLabel(meta('n1'))).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(); + const meta = stubMeta({ n1: null, n2: { label: null } }); + expect(nodeLabel(meta('n1'))).toBeUndefined(); + expect(nodeLabel(meta('n2'))).toBeUndefined(); }); }); @@ -205,10 +215,14 @@ describe('renderNodes', () => { }); it('renders the summary/preview split from describeNode end-to-end', () => { - const store = stubStore({ + const meta = stubMeta({ 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', + meta('n1'), + ); const xml = renderNodes([node]); expect(xml).toBe( ', + records: ReadonlyMap, nodeId: string, key: 'src', ): string | null { - const sidecar = store.readNode(nodeId); + const sidecar = records.get(nodeId); if (!sidecar) return null; const value = sidecar[key]; return typeof value === 'string' && value.length > 0 ? value : null; @@ -440,17 +442,18 @@ export interface ContextImage { * this image. */ async function loadContextImage( - store: ReturnType, + canvasId: string, + records: ReadonlyMap, node: CanvasNode, ): Promise { - const src = readSidecarString(store, node.id, 'src'); + const src = readSidecarString(records, 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 canvasBlobs(canvasId).read(src); if (!bytes) return null; return { node, resolvedSrc: src, bytes, mimeType, width, height }; } @@ -795,11 +798,11 @@ async function resampleImageBytes( * so repeated calls with the same parameters are O(1) cache hits. */ async function maybeResizeImageArtifact( - store: ReturnType, + canvasId: string, src: string, maxEdge: number, ): Promise<{ src: string; width: number; height: number } | null> { - const blobs = canvasBlobs(store.canvasId); + const blobs = canvasBlobs(canvasId); const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; if (!mimeType) return null; @@ -860,8 +863,7 @@ 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 canvas = await readCanvas(args.canvasId); if (!canvas) { throw new SnapshotNodeError( `Canvas ${args.canvasId} not found`, @@ -923,6 +925,13 @@ export async function snapshotNodesToArtifacts( }; for (const id of orderedIds) expand(id, false); + // Only the expanded set contributes: a snapshot needs each node's `src`, + // and nothing else in the Space is consulted. + const nodeRecords = await readCanvasNodes( + args.canvasId, + expansion.map((entry) => entry.id), + ); + const results: SnapshotNodeResult[] = []; // All snapshottable nodes (image + sketch) feed into the same // bucket-by-frame + spatial-cluster pipeline. There is no @@ -974,7 +983,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 = readSidecarString(nodeRecords, entry.node.id, 'src'); if (!src) { if (entry.fromFrame) continue; throw new SnapshotNodeError( @@ -982,7 +991,11 @@ export async function snapshotNodesToArtifacts( 'invalid_snapshot_request', ); } - const resized = await maybeResizeImageArtifact(store, src, maxEdge); + const resized = await maybeResizeImageArtifact( + args.canvasId, + src, + maxEdge, + ); if (resized) { results.push({ src: resized.src, @@ -1012,7 +1025,11 @@ export async function snapshotNodesToArtifacts( const contextImages: ContextImage[] = []; for (const entry of imageEntries) { - const loaded = await loadContextImage(store, entry.node); + const loaded = await loadContextImage( + args.canvasId, + nodeRecords, + entry.node, + ); if (loaded) { contextImages.push(loaded); continue; @@ -1025,7 +1042,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 = readSidecarString(nodeRecords, 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/