diff --git a/apps/server/src/modules/agent/agent-node.service.test.ts b/apps/server/src/modules/agent/agent-node.service.test.ts index 01ea97f79..04dd07003 100644 --- a/apps/server/src/modules/agent/agent-node.service.test.ts +++ b/apps/server/src/modules/agent/agent-node.service.test.ts @@ -62,7 +62,7 @@ function createHarness(options?: { : null, listSelectableProfileIds: () => options?.selectableIds ?? ['profile-a'], }), - readCanvasNodes: () => + readCanvasNodes: async () => options?.nodes === undefined ? [ { id: NOTE_ID, type: 'note' }, diff --git a/apps/server/src/modules/agent/agent-node.service.ts b/apps/server/src/modules/agent/agent-node.service.ts index 74b072e71..a7b8ba469 100644 --- a/apps/server/src/modules/agent/agent-node.service.ts +++ b/apps/server/src/modules/agent/agent-node.service.ts @@ -25,7 +25,7 @@ import { import { getLogger } from '../../utils/logger.js'; import { executeCanvasCommandsOnHost } from '../canvas/canvas-command-router.js'; import { buildSpatialBundle } from '../canvas/canvas-spatial.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { ExecuteOnServerOutput } from '../canvas/canvas-executor.js'; @@ -87,7 +87,7 @@ interface StoredNode { interface AgentNodeServiceDependencies { getProfileRegistry: () => AgentProfileRegistryPort | null; - readCanvasNodes: (canvasId: string) => StoredNode[] | null; + readCanvasNodes: (canvasId: string) => Promise; execute: (input: { canvasId: string; commands: readonly CanvasCommand[]; @@ -95,8 +95,10 @@ interface AgentNodeServiceDependencies { }) => Promise; } -function defaultReadCanvasNodes(canvasId: string): StoredNode[] | null { - const canvas = getCanvasStore(canvasId).read(); +async function defaultReadCanvasNodes( + canvasId: string, +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) return null; return canvas.state.nodes as StoredNode[]; } @@ -153,11 +155,11 @@ function resolveAnchor( return nodeId; } -export function resolveAgentNodePosition( +export async function resolveAgentNodePosition( canvasId: string, parentNodeId?: CanvasNodeId, -): Point { - const canvas = getCanvasStore(canvasId).read(); +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) { throw new AgentNodeCreationError( 'canvas_not_found', @@ -201,7 +203,7 @@ export class AgentNodeService { throw error; } - const nodes = this.dependencies.readCanvasNodes(input.canvasId); + const nodes = await this.dependencies.readCanvasNodes(input.canvasId); if (!nodes) { throw new AgentNodeCreationError( 'canvas_not_found', diff --git a/apps/server/src/modules/agent/agent-thread-resolver.test.ts b/apps/server/src/modules/agent/agent-thread-resolver.test.ts index 20f1a9b5b..21c3dbe5e 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.test.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.test.ts @@ -16,8 +16,8 @@ function createResolver( content = '', ) { return new AgentThreadResolver({ - readCanvasNodes: () => nodes, - readNodeContent: () => content, + readCanvasNodes: async () => nodes, + readNodeContent: async () => content, }); } @@ -41,8 +41,8 @@ const FIXED_NODE = { }; describe('AgentThreadResolver', () => { - it('resolves a fixed external Agent Node from Canvas storage', () => { - const target = createResolver( + it('resolves a fixed external Agent Node from Canvas storage', async () => { + const target = await createResolver( [FIXED_NODE], 'Existing prompt', ).resolveFixedAgentNode('canvas-a', 'thread-a'); @@ -65,36 +65,36 @@ describe('AgentThreadResolver', () => { }); }); - it('falls back for selectable or unrelated threads', () => { + it('falls back for selectable or unrelated threads', async () => { const selectable = { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' }, }; - expect( + await expect( createResolver([selectable]).resolveFixedAgentNode( 'canvas-a', 'thread-a', ), - ).toBeNull(); - expect( + ).resolves.toBeNull(); + await expect( createResolver([FIXED_NODE]).resolveFixedAgentNode( 'canvas-a', 'thread-other', ), - ).toBeNull(); + ).resolves.toBeNull(); }); - it('resolves any Question Node as a possible parent', () => { + it('resolves any Question Node as a possible parent', async () => { const selectable = { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBindingPolicy: 'selectable' }, }; - expect( + await expect( createResolver([selectable]).resolveAgentNodeId('canvas-a', 'thread-a'), - ).toBe('node-agent'); + ).resolves.toBe('node-agent'); }); - it('resolves a Huabu Agent binding for invocation', () => { + it('resolves a Huabu Agent binding for invocation', async () => { const internal = { ...FIXED_NODE, data: { @@ -103,19 +103,23 @@ describe('AgentThreadResolver', () => { }, }; expect( - createResolver([internal]).resolveFixedAgentNode('canvas-a', 'thread-a') - ?.agentBinding, + ( + await createResolver([internal]).resolveFixedAgentNode( + 'canvas-a', + 'thread-a', + ) + )?.agentBinding, ).toEqual({ kind: 'internal' }); }); - it('rejects duplicate threads and corrupt fixed-node metadata', () => { + it('rejects duplicate threads and corrupt fixed-node metadata', async () => { const duplicateResolver = createResolver([ FIXED_NODE, { ...FIXED_NODE, id: 'node-agent-2' }, ]); - expect(() => + await expect( duplicateResolver.resolveFixedAgentNode('canvas-a', 'thread-a'), - ).toThrowError( + ).rejects.toThrowError( expect.objectContaining>({ code: 'duplicate_thread', }), @@ -124,9 +128,9 @@ describe('AgentThreadResolver', () => { const corruptResolver = createResolver([ { ...FIXED_NODE, data: { ...FIXED_NODE.data, agentBinding: null } }, ]); - expect(() => + await expect( corruptResolver.resolveFixedAgentNode('canvas-a', 'thread-a'), - ).toThrowError( + ).rejects.toThrowError( expect.objectContaining>({ code: 'invalid_binding', }), diff --git a/apps/server/src/modules/agent/agent-thread-resolver.ts b/apps/server/src/modules/agent/agent-thread-resolver.ts index b8afef64e..f1a44ecb7 100644 --- a/apps/server/src/modules/agent/agent-thread-resolver.ts +++ b/apps/server/src/modules/agent/agent-thread-resolver.ts @@ -14,7 +14,7 @@ import { InvalidAgentLaunchOverridesError, parseAgentLaunchOverrides, } from './agent-launch-overrides.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; interface StoredNode { id: string; @@ -23,8 +23,8 @@ interface StoredNode { } interface ResolverDependencies { - readCanvasNodes: (canvasId: string) => StoredNode[] | null; - readNodeContent: (canvasId: string, nodeId: string) => string | null; + readCanvasNodes: (canvasId: string) => Promise; + readNodeContent: (canvasId: string, nodeId: string) => Promise; } export interface FixedAgentNodeTarget { @@ -55,12 +55,12 @@ export class AgentThreadResolutionError extends Error { } const DEFAULT_DEPENDENCIES: ResolverDependencies = { - readCanvasNodes: (canvasId) => { - const canvas = getCanvasStore(canvasId).read(); + readCanvasNodes: async (canvasId) => { + const canvas = await space(canvasId).read(); return canvas ? (canvas.state.nodes as StoredNode[]) : null; }, - readNodeContent: (canvasId, nodeId) => - getCanvasStore(canvasId).readNode(nodeId)?.content ?? null, + readNodeContent: async (canvasId, nodeId) => + (await space(canvasId).nodes.read(nodeId))?.record.content ?? null, }; /** @@ -74,8 +74,11 @@ export class AgentThreadResolver { private readonly dependencies: ResolverDependencies = DEFAULT_DEPENDENCIES, ) {} - resolveAgentNodeId(canvasId: string, threadId: string): CanvasNodeId | null { - const nodes = this.dependencies.readCanvasNodes(canvasId); + async resolveAgentNodeId( + canvasId: string, + threadId: string, + ): Promise { + const nodes = await this.dependencies.readCanvasNodes(canvasId); if (!nodes) { throw new AgentThreadResolutionError( 'canvas_not_found', @@ -93,11 +96,11 @@ export class AgentThreadResolver { return node?.type === 'question' ? (node.id as CanvasNodeId) : null; } - resolveFixedAgentNode( + async resolveFixedAgentNode( canvasId: string, threadId: string, - ): FixedAgentNodeTarget | null { - const nodes = this.dependencies.readCanvasNodes(canvasId); + ): Promise { + const nodes = await this.dependencies.readCanvasNodes(canvasId); if (!nodes) { throw new AgentThreadResolutionError( 'canvas_not_found', @@ -145,7 +148,7 @@ export class AgentThreadResolver { throw error; } - const content = this.dependencies.readNodeContent(canvasId, node.id); + const content = await this.dependencies.readNodeContent(canvasId, node.id); if (content === null) { throw new AgentThreadResolutionError( 'missing_node_content', diff --git a/apps/server/src/modules/agent/agent-thread.service.test.ts b/apps/server/src/modules/agent/agent-thread.service.test.ts index d1bfcc7c9..3d5232558 100644 --- a/apps/server/src/modules/agent/agent-thread.service.test.ts +++ b/apps/server/src/modules/agent/agent-thread.service.test.ts @@ -106,7 +106,7 @@ function createHarness(options?: { return emptyInternalStream(); }); const service = new AgentThreadService({ - resolveFixedAgentNode: () => + resolveFixedAgentNode: async () => options && 'target' in options ? (options.target ?? null) : TARGET, resolvePersistedExternalBinding: () => options && 'persistedBinding' in options @@ -164,7 +164,7 @@ describe('AgentThreadService', () => { expect(externalBindingFromWorkloadSpec({ binding: {} })).toBeNull(); }); - it('resolves a persisted external Thread without a fixed Agent Node', () => { + it('resolves a persisted external Thread without a fixed Agent Node', async () => { const binding = { kind: 'external' as const, profileId: 'profile-selectable', @@ -172,12 +172,12 @@ describe('AgentThreadService', () => { }; const harness = createHarness({ target: null, persistedBinding: binding }); - expect( + await expect( harness.service.resolveExternalTarget('canvas-a', 'thread-a'), - ).toEqual({ binding, fixedTarget: null }); + ).resolves.toEqual({ binding, fixedTarget: null }); }); - it('prefers the fixed Agent Node binding when one exists', () => { + it('prefers the fixed Agent Node binding when one exists', async () => { const harness = createHarness({ persistedBinding: { kind: 'external', @@ -186,9 +186,9 @@ describe('AgentThreadService', () => { }, }); - expect( + await expect( harness.service.resolveExternalTarget('canvas-a', 'thread-a'), - ).toEqual({ binding: TARGET.agentBinding, fixedTarget: TARGET }); + ).resolves.toEqual({ binding: TARGET.agentBinding, fixedTarget: TARGET }); }); it('uses persisted fixed binding and overrides under one leased lifecycle', async () => { @@ -356,7 +356,7 @@ describe('AgentThreadService', () => { return events([{ type: 'done', data: { message: 'Done' } }]); }); const service = new AgentThreadService({ - resolveFixedAgentNode: () => TARGET, + resolveFixedAgentNode: async () => TARGET, resolvePersistedExternalBinding: () => null, waitForTurnRelease: vi.fn().mockResolvedValue(undefined), acquireTurn: vi.fn(() => vi.fn()), diff --git a/apps/server/src/modules/agent/agent-thread.service.ts b/apps/server/src/modules/agent/agent-thread.service.ts index 1ab52dab2..64075e3de 100644 --- a/apps/server/src/modules/agent/agent-thread.service.ts +++ b/apps/server/src/modules/agent/agent-thread.service.ts @@ -34,7 +34,7 @@ interface AgentThreadServiceDependencies { resolveFixedAgentNode: ( canvasId: string, threadId: string, - ) => FixedAgentNodeTarget | null; + ) => Promise; resolvePersistedExternalBinding: ( canvasId: string, threadId: string, @@ -160,20 +160,20 @@ export class AgentThreadService { private readonly dependencies: AgentThreadServiceDependencies = DEFAULT_DEPENDENCIES, ) {} - resolveFixedTarget( + async resolveFixedTarget( canvasId: string | undefined, threadId: string, - ): FixedAgentNodeTarget | null { + ): Promise { return canvasId ? this.dependencies.resolveFixedAgentNode(canvasId, threadId) : null; } - resolveExternalTarget( + async resolveExternalTarget( canvasId: string, threadId: string, - ): ExternalAgentThreadTarget | null { - const fixedTarget = this.resolveFixedTarget(canvasId, threadId); + ): Promise { + const fixedTarget = await this.resolveFixedTarget(canvasId, threadId); if (fixedTarget) { return fixedTarget.agentBinding.kind === 'external' ? { binding: fixedTarget.agentBinding, fixedTarget } @@ -197,7 +197,7 @@ export class AgentThreadService { ): Promise { const fixedTarget = options.fixedTarget === undefined - ? this.resolveFixedTarget(options.canvasId, options.threadId) + ? await this.resolveFixedTarget(options.canvasId, options.threadId) : options.fixedTarget; const binding: AgentBinding = fixedTarget?.agentBinding ?? options.requestBinding ?? { kind: 'internal' }; diff --git a/apps/server/src/modules/agent/agent.route.ts b/apps/server/src/modules/agent/agent.route.ts index 597a2fb53..1ce5a34e2 100644 --- a/apps/server/src/modules/agent/agent.route.ts +++ b/apps/server/src/modules/agent/agent.route.ts @@ -546,7 +546,7 @@ const agentRoutes: FastifyPluginAsync = async ( } = parsed.data; const resolvedThreadId = getOrCreateThreadId(threadId); - const fixedTarget = agentThreadService.resolveFixedTarget( + const fixedTarget = await agentThreadService.resolveFixedTarget( canvasId, resolvedThreadId, ); diff --git a/apps/server/src/modules/agent/conversation/envelope.test.ts b/apps/server/src/modules/agent/conversation/envelope.test.ts new file mode 100644 index 000000000..e13946f6b --- /dev/null +++ b/apps/server/src/modules/agent/conversation/envelope.test.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const readMany = vi.hoisted(() => vi.fn()); + +vi.mock('../../storage/index.js', () => ({ + space: () => ({ + nodes: { + readMany, + read: vi.fn(), + }, + }), +})); + +import { buildChatEnvelope } from './envelope.js'; + +import type { NodeContent, NodeSnapshot } from '../../storage/index.js'; +import type { FastifyBaseLogger } from 'fastify'; + +const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +} as unknown as FastifyBaseLogger; + +function snapshot(nodeId: string, content: string): NodeSnapshot { + return { + record: { + nodeId, + type: 'note', + label: `Label ${nodeId}`, + content, + } as NodeContent, + revision: `revision-${nodeId}`, + }; +} + +describe('buildChatEnvelope selection records', () => { + beforeEach(() => { + readMany.mockReset(); + readMany.mockImplementation(async (nodeIds: readonly string[]) => { + const available = new Map([ + ['frame-1', snapshot('frame-1', 'Frame body')], + ['child-1', snapshot('child-1', 'Child body')], + ]); + return new Map( + nodeIds.flatMap((nodeId) => { + const record = available.get(nodeId); + return record ? [[nodeId, record] as const] : []; + }), + ); + }); + }); + + it('enriches recursively included frame children from their records', async () => { + const envelope = await buildChatEnvelope({ + content: 'Review this frame', + attachments: [], + selectedNodes: [ + { + id: 'frame-1', + type: 'frame', + children: [{ id: 'child-1', type: 'note' }], + }, + ], + canvasId: 'canvas-1', + logger, + }); + + expect(readMany).toHaveBeenCalledWith(['frame-1', 'child-1']); + expect(envelope.focus.selection.refs).toEqual([ + expect.objectContaining({ id: 'frame-1', preview: 'Frame body' }), + expect.objectContaining({ id: 'child-1', preview: 'Child body' }), + ]); + expect(envelope.focus.selection.selectedIds).toEqual(['frame-1']); + }); +}); diff --git a/apps/server/src/modules/agent/conversation/envelope.ts b/apps/server/src/modules/agent/conversation/envelope.ts index f4250ec7b..f0bc00841 100644 --- a/apps/server/src/modules/agent/conversation/envelope.ts +++ b/apps/server/src/modules/agent/conversation/envelope.ts @@ -24,10 +24,11 @@ import { getSkill } from '../../../prompt/index.js'; import { getNodeNeighbourhood } from '../../canvas/node-neighbourhood.js'; import { describeNode } from '../../canvas/node-prompt.js'; import { snapshotNodesToArtifacts } from '../../canvas/snapshot-nodes.js'; -import { getCanvasStore } from '../../storage/index.js'; +import { space } from '../../storage/index.js'; import { isUserInvokableSkill } from '../skills.route.js'; import type { NodeNeighbourhoodContext } from '../../canvas/node-neighbourhood.js'; +import type { NodeSnapshot } from '../../storage/index.js'; import type { AgentNodePreview } from '../node-ref.js'; import type { ChatAttachment, @@ -200,21 +201,26 @@ function collectSketchStrokeSubsets( * The `preview` is picked server-side via the shared * {@link extractAgentNodePreview} ladder (`summary > content[:120] > src`) * — the SAME policy the node-neighbourhood uses — by reading each node's - * on-disk sidecar from the canvas store. Full content is still one tool - * call away; the preview is only a scan hint. When `canvasId` is null the - * store is unavailable, so refs fall back to bare `{ id, type, label?, - * filename }` (no preview). + * stored record. Full content is still one tool call away; the preview is + * only a scan hint. When `canvasId` is null there is no Space to read, so + * refs fall back to bare `{ id, type, label?, filename }` (no preview). + * + * A selection is a named subset, so it is read as one — `readMany` over the + * ids the wire already named, rather than a scan of the Space or a read per + * node (§12.6.1). */ -function collectSelectedNodeRefs( +async function collectSelectedNodeRefs( nodes: WireSelectionNode[], canvasId: string | null, -): AgentNodePreview[] { - let store: ReturnType | null = null; +): Promise { + let records = new Map(); if (canvasId) { try { - store = getCanvasStore(canvasId); + records = await space(canvasId).nodes.readMany( + collectSelectionNodeIds(nodes), + ); } catch { - store = null; + /* Space unreadable — refs stay bare. */ } } const refs: AgentNodePreview[] = []; @@ -226,7 +232,6 @@ function collectSelectedNodeRefs( // label + file + preview + rev for every server-side node context. refs.push( describeNode( - store, { id: n.id, type: n.type, @@ -234,6 +239,7 @@ function collectSelectedNodeRefs( ...(n.src !== undefined ? { src: n.src } : {}), }, 'preview', + records.get(n.id)?.record ?? null, ), ); if (n.children) walk(n.children); @@ -243,6 +249,19 @@ function collectSelectedNodeRefs( return refs; } +/** Collect every node represented by the recursive selection wire. */ +function collectSelectionNodeIds(nodes: WireSelectionNode[]): string[] { + const seen = new Set(); + const walk = (list: WireSelectionNode[]) => { + for (const node of list) { + seen.add(node.id); + if (node.children) walk(node.children); + } + }; + walk(nodes); + return [...seen]; +} + /** * Collect the ids the user **explicitly selected** (top-level only — * frame children are intentionally skipped). @@ -408,13 +427,10 @@ export async function buildChatEnvelope( let anchor: ChatEnvelope['focus']['anchor']; if (anchorNodeId && canvasId) { const neighbourhood = - getNodeNeighbourhood(canvasId, anchorNodeId) ?? undefined; + (await getNodeNeighbourhood(canvasId, anchorNodeId)) ?? undefined; let label: string | undefined; try { - const meta = getCanvasStore(canvasId).readNode(anchorNodeId) as Record< - string, - unknown - > | null; + const meta = (await space(canvasId).nodes.read(anchorNodeId))?.record; if (typeof meta?.label === 'string') label = meta.label; } catch { /* store unavailable — anchor still useful by id */ @@ -427,6 +443,9 @@ export async function buildChatEnvelope( } // Focus: selection refs + derived snapshot artifacts. + const selectionRefs = selectedNodes + ? await collectSelectedNodeRefs(selectedNodes, canvasId) + : []; const selectionImageAttachments = selectedNodes ? collectImageAttachments(selectedNodes) : []; @@ -462,9 +481,7 @@ export async function buildChatEnvelope( }, focus: { selection: { - refs: selectedNodes - ? collectSelectedNodeRefs(selectedNodes, canvasId) - : [], + refs: selectionRefs, selectedIds: selectedNodes ? collectSelectedNodeIds(selectedNodes) : [], imageAttachments: dedupedImageAttachments, snapshotAttachments, diff --git a/apps/server/src/modules/agent/tools/executor.ts b/apps/server/src/modules/agent/tools/executor.ts index deca047e6..95c1d0e60 100644 --- a/apps/server/src/modules/agent/tools/executor.ts +++ b/apps/server/src/modules/agent/tools/executor.ts @@ -65,6 +65,7 @@ import { } from './handlers/task.js'; import { handleWebSearch, type WebSearchArgs } from './handlers/web-search.js'; import { resolveWorldReadCanvasId } from '../../canvas/world-target-access.js'; +import { acquireWorkspaceOperationLease } from '../../workspace.js'; import type { AgentToolResult } from '@earendil-works/pi-agent-core'; import type { NodeOrigin } from '@huabu/shared'; @@ -119,10 +120,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,45 +132,75 @@ export async function executeTool( const { targetCanvasId: _targetCanvasId, ...toolArgs } = value; return { ...toolArgs, - canvasId: resolveWorldReadCanvasId(ownerCanvasId, requested), + canvasId: await resolveWorldReadCanvasId(ownerCanvasId, requested), } as unknown as T; }; + const withStableWorkspace = async ( + operation: () => T | Promise, + ): Promise => { + // Cross-Space read authorization and the handler that consumes it are one + // operation. The resolver cannot release the Workspace before the handler + // runs without reopening a switch window between the check and the read. + const lease = acquireWorkspaceOperationLease(); + try { + return await operation(); + } finally { + lease.release(); + } + }; switch (name) { case 'web_search': return handleWebSearch(args as WebSearchArgs); case 'get_space_outline': - return handleGetCanvasOutline( - withReadCanvasId(args, 'get_space_outline'), + return withStableWorkspace(async () => + handleGetCanvasOutline( + await withReadCanvasId( + args, + 'get_space_outline', + ), + ), ); case 'inspect_nodes': - return handleInspectNodes( - withReadCanvasId(args, 'inspect_nodes'), + return withStableWorkspace(async () => + handleInspectNodes( + await withReadCanvasId(args, 'inspect_nodes'), + ), ); case 'inspect_edges': - return handleInspectEdges( - withReadCanvasId(args, 'inspect_edges'), + return withStableWorkspace(async () => + handleInspectEdges( + await withReadCanvasId(args, 'inspect_edges'), + ), ); case 'grep': - return handleGrep(withReadCanvasId(args, 'grep')); + return withStableWorkspace(async () => + handleGrep(await withReadCanvasId(args, 'grep')), + ); case 'find': - return handleFind(withReadCanvasId(args, 'find')); + return withStableWorkspace(async () => + handleFind(await withReadCanvasId(args, 'find')), + ); case 'ls': - return handleLs(withReadCanvasId(args, 'ls')); + return withStableWorkspace(async () => + handleLs(await withReadCanvasId(args, 'ls')), + ); case 'read': { const ownerCanvasId = requireCanvasId('read'); - const readArgs = withReadCanvasId(args, 'read'); - return handleRead( - readArgs, - readArgs.canvasId === ownerCanvasId ? context?.readSet : undefined, - ); + return withStableWorkspace(async () => { + const readArgs = await withReadCanvasId(args, 'read'); + return handleRead( + readArgs, + readArgs.canvasId === ownerCanvasId ? context?.readSet : undefined, + ); + }); } case 'space_commands': 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..c3abcc468 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-read.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-read.ts @@ -39,7 +39,7 @@ import { normalizeRel, safeResolve } from './fs-sandbox.js'; import { readSkillFile, resolveSkillPath } from '../../../../prompt/index.js'; import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js'; import { IMAGE_MIME_MAP, isVisionImageMime } from '../../../../utils/mime.js'; -import { getCanvasStore } from '../../../storage/index.js'; +import { space } from '../../../storage/index.js'; import { readCanvasMemory, readWorkspaceMemory } from '../../memory/index.js'; import type { readParamsSchema } from '../definitions.js'; @@ -76,25 +76,26 @@ const NODE_FILE_RE = /^nodes\/[^/]+\.md$/; * content, and the same revision used by executor CAS. When supplied, the * turn read-set receives that revision for guarded follow-up writes. */ -function projectNodeRead( +async function projectNodeRead( rel: string, canvasId: string, fileText: string, readSet?: Map, -): +): Promise< | { content: string; frontmatter: Record; rev: string; } - | undefined { + | undefined +> { if (!NODE_FILE_RE.test(rel)) return undefined; const parsed = parseFrontmatter(fileText); const rawId = parsed.meta['id']; const nodeId = typeof rawId === 'string' && rawId ? rawId : undefined; if (!nodeId) return undefined; try { - const nc = getCanvasStore(canvasId).readNode(nodeId); + const nc = (await space(canvasId).nodes.read(nodeId))?.record; if (!nc) return undefined; const rev = nodeRevisionOf({ content: nc.content, @@ -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 62ef0b5d2..90de3f1b6 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-sandbox.ts @@ -30,7 +30,7 @@ import { readFileSync, readdirSync, statSync, type Dirent } from 'node:fs'; import path from 'node:path'; import { parseFrontmatter } from '../../../../utils/markdown-frontmatter.js'; -import { getCanvasStore, space } from '../../../storage/index.js'; +import { space } from '../../../storage/index.js'; // ─── Always-skipped directory names ───────────────────────────────────────── @@ -320,22 +320,27 @@ export interface NodeMeta { * frontmatter `id:` plus `space.json` metadata. * Returns `null` otherwise. */ -export function makeNodeLookup( +export async function makeNodeLookup( canvasId: string, -): (canvasRelPath: string) => NodeMeta | null { +): Promise<(canvasRelPath: string) => NodeMeta | null> { + // The Space record is read up front rather than inside the lazy build: the + // returned lookup is called synchronously from the search passes, and only + // this half of the work crosses the storage port. The directory scan below + // stays where it is — the built-in file tools are Disk-only (§6.4.3, + // disposition A) and this maps real filenames to records. + let file; + try { + file = await space(canvasId).read(); + } catch { + file = null; + } + let cache: Map | null = null; const ensure = (): Map => { if (cache) return cache; const byId = new Map(); const byPath = new Map(); - - let file; - try { - file = getCanvasStore(canvasId).read(); - } catch { - file = null; - } if (file) { const nodes = (file.state.nodes ?? []) as Array>; for (const n of nodes) { diff --git a/apps/server/src/modules/agent/tools/handlers/fs-search.ts b/apps/server/src/modules/agent/tools/handlers/fs-search.ts index d4fe60e43..a90c68b85 100644 --- a/apps/server/src/modules/agent/tools/handlers/fs-search.ts +++ b/apps/server/src/modules/agent/tools/handlers/fs-search.ts @@ -168,7 +168,7 @@ export async function handleGrep(args: GrepArgs): Promise { const globRe = effectiveGlob ? globToRegExp(effectiveGlob) : null; const effectiveLimit = Math.max(1, limit ?? DEFAULT_GREP_LIMIT); const ctxN = Math.max(0, ctxLines ?? 0); - const lookup = makeNodeLookup(args.canvasId); + const lookup = await makeNodeLookup(args.canvasId); const deadline = Date.now() + GREP_DEADLINE_MS; // Enumerate candidate files, recording each as a canvas-relative @@ -289,7 +289,7 @@ export async function handleFind(args: FindArgs): Promise { } const effectiveLimit = Math.max(1, limit ?? DEFAULT_FIND_LIMIT); - const lookup = makeNodeLookup(args.canvasId); + const lookup = await makeNodeLookup(args.canvasId); const results: Array> = []; let truncated = false; diff --git a/apps/server/src/modules/agent/tools/world-target-read.test.ts b/apps/server/src/modules/agent/tools/world-target-read.test.ts index ce4995afc..bd7a6eff7 100644 --- a/apps/server/src/modules/agent/tools/world-target-read.test.ts +++ b/apps/server/src/modules/agent/tools/world-target-read.test.ts @@ -7,21 +7,35 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const workspaceState = vi.hoisted(() => ({ path: '' })); +const workspaceState = vi.hoisted(() => ({ path: '', leases: 0 })); vi.mock('../../workspace.js', () => ({ getWorkspacePath: () => workspaceState.path, + acquireWorkspaceOperationLease: () => { + const workspacePath = workspaceState.path; + workspaceState.leases += 1; + let released = false; + return { + workspacePath, + release: () => { + if (released) return; + released = true; + workspaceState.leases -= 1; + }, + }; + }, })); import { executeTool } from './executor.js'; import { refreshCanvasDirIndex } from '../../storage/canvas-dirs.js'; -function writeCanvas( +function writeCanvasAt( + workspacePath: string, directory: string, canvasId: string, nodes: unknown[], ): void { - const root = path.join(workspaceState.path, directory); + const root = path.join(workspacePath, directory); mkdirSync(root, { recursive: true }); writeFileSync( path.join(root, 'space.json'), @@ -37,10 +51,27 @@ function writeCanvas( ); } +function writeCanvas( + directory: string, + canvasId: string, + nodes: unknown[], +): void { + writeCanvasAt(workspaceState.path, directory, canvasId, nodes); +} + +function switchWorkspace(nextPath: string): void { + if (workspaceState.leases > 0) { + throw new Error('Workspace operation in progress'); + } + workspaceState.path = nextPath; + refreshCanvasDirIndex(); +} + beforeEach(() => { workspaceState.path = mkdtempSync( path.join(tmpdir(), 'huabu-world-target-read-'), ); + workspaceState.leases = 0; writeCanvas('.world', 'canvas-world', [ { id: 'node-portal', @@ -126,4 +157,53 @@ describe('World target reads', () => { ), ).rejects.toThrow(); }); + + it('keeps portal authorization and the target read in one Workspace', async () => { + const originalWorkspace = workspaceState.path; + const otherWorkspace = mkdtempSync( + path.join(tmpdir(), 'huabu-world-target-read-other-'), + ); + writeCanvasAt(otherWorkspace, '.world', 'canvas-world', [ + { + id: 'node-portal', + type: 'canvasRef', + position: { x: 0, y: 0 }, + data: { targetCanvasId: 'canvas-a' }, + }, + ]); + writeCanvasAt(otherWorkspace, 'Other Project', 'canvas-a', [ + { + id: 'node-other', + type: 'note', + position: { x: 0, y: 0 }, + data: {}, + }, + ]); + + let switchError: unknown; + queueMicrotask(() => { + try { + switchWorkspace(otherWorkspace); + } catch (error) { + switchError = error; + } + }); + + try { + const result = JSON.parse( + (await executeTool( + 'get_space_outline', + { targetCanvasId: 'canvas-a' }, + { canvasId: 'canvas-world' }, + )) as string, + ) as { nodes: Array<{ id: string }> }; + + expect(switchError).toBeInstanceOf(Error); + expect(workspaceState.path).toBe(originalWorkspace); + expect(result.nodes.map((node) => node.id)).toEqual(['node-source']); + } finally { + workspaceState.path = originalWorkspace; + rmSync(otherWorkspace, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/modules/canvas/canvas-command-router.ts b/apps/server/src/modules/canvas/canvas-command-router.ts index fc38724ed..e85d14d6f 100644 --- a/apps/server/src/modules/canvas/canvas-command-router.ts +++ b/apps/server/src/modules/canvas/canvas-command-router.ts @@ -23,11 +23,7 @@ import { } from './canvas-executor.js'; import { reconcileWorldPortals } from './world-portals.js'; import { createKeyedMutex } from '../../utils/keyed-mutex.js'; -import { - listCanvasDirEntries, - requireWorldCanvasId, -} from '../storage/canvas-dirs.js'; -import { getCanvasStore } from '../storage/index.js'; +import { getStructuredStore, space } from '../storage/index.js'; type PortalCommand = Extract; const withPortalRoutingMutex = createKeyedMutex(); @@ -210,7 +206,7 @@ async function ensureCanonicalPortals( worldCanvasId: string, commands: readonly PortalCommand[], ): Promise { - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const world = (await space(worldCanvasId).read()) as StoredCanvas | null; const targets = new Set(); for (const node of (world?.state.nodes ?? []) as NestableNode[]) { const target = portalTarget(node); @@ -249,14 +245,15 @@ async function executeCanvasCommandsOnHostInternal( assertConsistentDesiredStates(portalCommands); - const worldCanvasId = requireWorldCanvasId(); + const spaces = getStructuredStore().spaces(); + const worldCanvasId = await spaces.worldId(); if (!routingLockHeld) { return withPortalRoutingMutex(worldCanvasId, () => executeCanvasCommandsOnHostInternal(input, true), ); } await ensureCanonicalPortals(worldCanvasId, portalCommands); - const world = getCanvasStore(worldCanvasId).read() as StoredCanvas | null; + const world = (await space(worldCanvasId).read()) as StoredCanvas | null; if (!world || !Array.isArray(world.state.nodes)) { throw new CanvasCommandRoutingError('World Canvas is unavailable'); } @@ -335,18 +332,39 @@ async function executeCanvasCommandsOnHostInternal( } const liveCanvasIds = new Set( - listCanvasDirEntries().map((entry) => entry.id), + (await spaces.list()).map((summary) => summary.canvasId), ); - const sourceStates = new Map(); - const readSource = (canvasId: string): SourceState | null => { - if (!sourceStates.has(canvasId)) { - sourceStates.set( - canvasId, - sourceStateOf(getCanvasStore(canvasId).read() as StoredCanvas | null), - ); + + // Every source Space the passes below can ask about, read once up front. + // The set is knowable without running them: a source is either named by a + // Portal-Pin update or referenced by a World node, and both are already in + // hand. That keeps the reads to what this command touches while letting the + // passes themselves stay synchronous. + const referencedCanvasIds = new Set(); + for (const command of portalCommands) { + for (const update of command.updates) { + referencedCanvasIds.add(update.sourceCanvasId); } - return sourceStates.get(canvasId) ?? null; - }; + } + for (const node of worldNodes) { + const target = referenceTarget(node); + if (target) referencedCanvasIds.add(target.canvasId); + } + const sourceStates = new Map( + await Promise.all( + [...referencedCanvasIds].map( + async (canvasId) => + [ + canvasId, + sourceStateOf( + (await space(canvasId).read()) as StoredCanvas | null, + ), + ] as const, + ), + ), + ); + const readSource = (canvasId: string): SourceState | null => + sourceStates.get(canvasId) ?? null; const requested = new Map(); for (const command of portalCommands) { diff --git a/apps/server/src/modules/canvas/canvas-executor.ts b/apps/server/src/modules/canvas/canvas-executor.ts index a59a62b46..97b5eadc7 100644 --- a/apps/server/src/modules/canvas/canvas-executor.ts +++ b/apps/server/src/modules/canvas/canvas-executor.ts @@ -65,14 +65,12 @@ import { import { getLogger } from '../../utils/logger.js'; import { space, - getCanvasStore, - getStructuredStore, withCanvasMutex, type BlobScope, type CanvasFile, - type CanvasStore, type DeltaLogEntry, type NodeContent, + type NodeSnapshot, type SpaceNodeMutation, } from '../storage/index.js'; @@ -140,7 +138,7 @@ function stripNodesForCanvas(nodes: readonly CanvasNode[]): CanvasNode[] { } function hydrateNodes( - store: CanvasStore, + records: ReadonlyMap, nodes: readonly CanvasNode[], ): CanvasNode[] { return nodes.map((node) => { @@ -149,12 +147,7 @@ function hydrateNodes( const nodeType = typeof node.type === 'string' ? node.type : ''; if (!MD_BACKED_NODE_TYPES.has(nodeType)) return { ...node }; - let content: NodeContent | null = null; - try { - content = store.readNode(nodeId); - } catch { - content = null; - } + const content = records.get(nodeId)?.record ?? null; if (!content) return { ...node }; const data: Record = { ...(node.data ?? {}) }; @@ -692,11 +685,7 @@ export async function executeOnServer( // writes to the same canvas. Idempotent for values that are already artifact // keys / `/api/` URLs / `data:` URIs. if (originator.source === 'agent') { - commands = await importForeignNodeSources( - getCanvasStore(canvasId), - canvasId, - commands, - ); + commands = await importForeignNodeSources(canvasId, commands); // For image nodes with only width specified, calculate height from actual // image aspect ratio. This ensures correct proportions for all image sources. @@ -704,18 +693,21 @@ export async function executeOnServer( } return await withCanvasMutex(canvasId, async () => { - const store = getCanvasStore(canvasId); - const handle = getStructuredStore().space(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); + // Executor prestate is whole-Space work: every md-backed node in the + // topology needs its stored content before the engine sees it. + const records = await handle.nodes.list(); + const fromVersion = canvas.version; // Hydrate per-node content from .md sidecars before the engine sees // the prestate — handlers like MERGE_NODE_DATA need the current // `data.content` to merge against, but topology never carries it. const prestateNodes = hydrateNodes( - store, + records, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; @@ -1123,14 +1115,17 @@ export async function applyDeltasOnServer(input: { const { canvasId, originator, runId } = input; return await withCanvasMutex(canvasId, async () => { - const store = getCanvasStore(canvasId); - const handle = getStructuredStore().space(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) throw new CanvasNotFoundError(canvasId); + // Executor prestate is whole-Space work: every md-backed node in the + // topology needs its stored content before the engine sees it. + const records = await handle.nodes.list(); + const fromVersion = canvas.version; const prestateNodes = hydrateNodes( - store, + records, canvas.state.nodes as CanvasNode[], ); const prestateEdges = (canvas.state.edges ?? []) as CanvasEdge[]; diff --git a/apps/server/src/modules/canvas/canvas-search.test.ts b/apps/server/src/modules/canvas/canvas-search.test.ts index 9677f057b..22cc2c60c 100644 --- a/apps/server/src/modules/canvas/canvas-search.test.ts +++ b/apps/server/src/modules/canvas/canvas-search.test.ts @@ -7,7 +7,7 @@ * Covers the streaming {@link searchCanvas} driver. The route layer is * a thin NDJSON adapter, so the interesting behaviour — tiered emission * order, field filtering, snippet construction, limits, abort semantics - * — all lives here and is exercised against a fake `CanvasStore` whose + * — all lives here and is exercised against a fake `Space` whose * `streamAllNodes` walks an in-memory snapshot. Production reads sidecars * off disk, but the scanner takes a callback so the two are wire-compatible. */ @@ -24,7 +24,7 @@ import { import { createChatSubmission } from '../agent/agenetes/handle.js'; import type { ChatEnvelope } from '../agent/conversation/envelope.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent, NodeSnapshot, Space } from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; import type { CanvasSearchEvent, CanvasSearchRequest } from '@huabu/shared'; @@ -76,16 +76,16 @@ function mkEdge( } /** - * Build a duck-typed `CanvasStore` that satisfies just the two methods + * Build a duck-typed `Space` that satisfies just the two members * `searchCanvas` actually reads from: `read()` (for the static node + edge * shape) and `streamAllNodes()` (for the sidecar bodies). The rest of the * store surface is irrelevant here, so we cast through `unknown`. */ -function makeFakeStore(opts: { +function makeFakeSpace(opts: { nodes: readonly SearchableNode[]; contents: readonly NodeContent[]; edges?: readonly SearchableEdge[]; -}): CanvasStore { +}): Space { const stateNodes = opts.nodes.map((n) => ({ id: n.id, type: n.type, @@ -100,21 +100,25 @@ function makeFakeStore(opts: { })); const fake = { canvasId: 'test-canvas', - read: () => ({ state: { nodes: stateNodes, edges: stateEdges } }), - streamAllNodes: async ( - onNode: (id: string, content: NodeContent) => void, - signal?: { readonly aborted: boolean }, - ): Promise> => { - const map = new Map(); - for (const c of opts.contents) { - if (signal?.aborted) return map; - map.set(c.nodeId, c); - onNode(c.nodeId, c); - } - return map; + read: async () => ({ state: { nodes: stateNodes, edges: stateEdges } }), + nodes: { + canvasId: 'test-canvas', + stream: async ( + onNode: (snapshot: NodeSnapshot) => void, + options?: { signal?: { readonly aborted: boolean } }, + ): Promise> => { + const map = new Map(); + for (const c of opts.contents) { + if (options?.signal?.aborted) return map; + const snapshot = { record: c, revision: `rev-${c.nodeId}` }; + map.set(c.nodeId, snapshot); + onNode(snapshot); + } + return map; + }, }, }; - return fake as unknown as CanvasStore; + return fake as unknown as Space; } async function collect( @@ -124,9 +128,9 @@ async function collect( signal?: AbortSignal, edges?: SearchableEdge[], ): Promise { - const store = makeFakeStore({ nodes, contents, edges }); + const handle = makeFakeSpace({ nodes, contents, edges }); const events: CanvasSearchEvent[] = []; - await searchCanvas(store, request, (e) => events.push(e), signal); + await searchCanvas(handle, request, (e) => events.push(e), signal); return events; } @@ -390,9 +394,9 @@ describe('searchCanvas — abort', () => { } const ctrl = new AbortController(); const events: CanvasSearchEvent[] = []; - const store = makeFakeStore({ nodes, contents }); + const handle = makeFakeSpace({ nodes, contents }); await searchCanvas( - store, + handle, { query: 'hit' }, (e) => { events.push(e); diff --git a/apps/server/src/modules/canvas/canvas-search.ts b/apps/server/src/modules/canvas/canvas-search.ts index 17e0754d6..3be81d2c1 100644 --- a/apps/server/src/modules/canvas/canvas-search.ts +++ b/apps/server/src/modules/canvas/canvas-search.ts @@ -46,7 +46,7 @@ import { agenetes } from '../agent/agenetes/drivers.js'; import { chatEnvelopeFromSubmission } from '../agent/agenetes/handle.js'; import { canvasAcpNamespace } from '../workspace/paths.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent, Space } from '../storage/index.js'; import type { AgentTurn } from '@agenetes/protocol'; /** Window of characters shown around each match in `snippet`. */ @@ -531,20 +531,20 @@ export function extractSearchableEdges(state: unknown): SearchableEdge[] { } /** - * Drive a search directly off a {@link CanvasStore}. + * Drive a search off one Space. * - * Streams meta-tier matches as each sidecar lands (no `await`-all + * Streams meta-tier matches as each record lands (no `await`-all * barrier in front of the first emit). Content tier runs after the * stream settles, scanning the in-memory cache that was built as a - * side effect of the meta walk — zero extra disk reads. + * side effect of the meta walk — zero extra reads. */ export async function searchCanvas( - store: CanvasStore, + handle: Space, request: CanvasSearchRequest, emit: (event: CanvasSearchEvent) => void, signal?: AbortSignal, ): Promise { - const file = store.read(); + const file = await handle.read(); if (!file) { emit({ type: 'error', message: 'Canvas not found' }); return; @@ -583,22 +583,31 @@ export async function searchCanvas( // invokes the callback synchronously as each file resolves, so a // match against the very first parsed file ships before any of the // remaining files are even opened. - const contentByNodeId = await store.streamAllNodes((id, content) => { - if (signal?.aborted) return; - if (!wantsMeta) return; - if (totalEmitted >= limit) { - truncated = true; - return; - } - const node = candidateById.get(id); - // Skip sidecars that belong to nodes filtered out by - // `nodeId` / `nodeTypes` — we still read them (the directory - // walk is unfiltered) but they contribute nothing here. - if (!node) return; - scanNodeMeta(node, content, fields, needleLower, needleLen, (m) => - tryEmitMatch('meta', m), - ); - }, signal); + const streamed = await handle.nodes.stream( + (snapshot) => { + const id = snapshot.record.nodeId; + const content = snapshot.record; + if (signal?.aborted) return; + if (!wantsMeta) return; + if (totalEmitted >= limit) { + truncated = true; + return; + } + const node = candidateById.get(id); + // Skip sidecars that belong to nodes filtered out by + // `nodeId` / `nodeTypes` — we still read them (the directory + // walk is unfiltered) but they contribute nothing here. + if (!node) return; + scanNodeMeta(node, content, fields, needleLower, needleLen, (m) => + tryEmitMatch('meta', m), + ); + }, + signal ? { signal } : undefined, + ); + const contentByNodeId = new Map(); + for (const [id, snapshot] of streamed) { + contentByNodeId.set(id, snapshot.record); + } if (signal?.aborted) return; @@ -677,7 +686,7 @@ export async function searchCanvas( const label = content?.label ?? null; scanNodeConversation( node, - store.canvasId, + handle.canvasId, label, needleLower, needleLen, diff --git a/apps/server/src/modules/canvas/canvas-spatial.test.ts b/apps/server/src/modules/canvas/canvas-spatial.test.ts index 70b98ceb0..a95900181 100644 --- a/apps/server/src/modules/canvas/canvas-spatial.test.ts +++ b/apps/server/src/modules/canvas/canvas-spatial.test.ts @@ -50,7 +50,7 @@ function seed(canvasId: string, nodes: SeedNode[]): void { }); } -function byId(result: ReturnType): Map< +function byId(result: Awaited>): Map< string, { position: { x: number; y: number }; @@ -113,18 +113,18 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { ]); } - it('root node reports position == absolutePosition', () => { + it('root node reports position == absolutePosition', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['root'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['root'] })); expect(m.get('root')).toEqual({ position: { x: 100, y: 100 }, absolutePosition: { x: 100, y: 100 }, }); }); - it('framed child reports parent-local position and world absolutePosition', () => { + it('framed child reports parent-local position and world absolutePosition', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['child'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['child'] })); expect(m.get('child')).toEqual({ // raw stored value = frame-relative position: { x: 50, y: 60 }, @@ -133,9 +133,9 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { }); }); - it('resolves absolutePosition through a nested frame chain', () => { + it('resolves absolutePosition through a nested frame chain', async () => { seedScene(); - const m = byId(inspectNodes(CANVAS, { ids: ['grandchild'] })); + const m = byId(await inspectNodes(CANVAS, { ids: ['grandchild'] })); expect(m.get('grandchild')).toEqual({ // raw stored value = relative to inner frame position: { x: 10, y: 20 }, @@ -144,9 +144,9 @@ describe('inspect_nodes / outline — dual-field coordinates', () => { }); }); - it('get_canvas_outline emits the same dual fields', () => { + it('get_canvas_outline emits the same dual fields', async () => { seedScene(); - const outline = buildCanvasOutline(CANVAS); + const outline = await buildCanvasOutline(CANVAS); if (!outline) throw new Error('no outline'); const child = outline.nodes.find((n) => n.id === 'child'); expect(child?.position).toEqual({ x: 50, y: 60 }); diff --git a/apps/server/src/modules/canvas/canvas-spatial.ts b/apps/server/src/modules/canvas/canvas-spatial.ts index d96911163..06a2245f1 100644 --- a/apps/server/src/modules/canvas/canvas-spatial.ts +++ b/apps/server/src/modules/canvas/canvas-spatial.ts @@ -53,10 +53,10 @@ import { } from '@huabu/shared/canvas-engine'; import { describeNode, nodeLabel, type NodeInput } from './node-prompt.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { AgentNodeOutline } from '../agent/node-ref.js'; -import type { CanvasFile } from '../storage/canvas-store.js'; +import type { CanvasFile } from '../storage/index.js'; import type { CanvasNodeType, CardinalDirection, @@ -282,14 +282,20 @@ export interface CanvasOutlineOpts { * Build the one-shot "map" of a canvas: every node's geometry + edges + * spatial clusters. Returns `null` when the canvas does not exist. */ -export function buildCanvasOutline( +export async function buildCanvasOutline( canvasId: string, opts: CanvasOutlineOpts = {}, -): CanvasOutline | null { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return null; + // Whole-Space work: the outline describes every node, so one scan is the + // shape (§12.6.1). It also replaces the lazy per-node reads the memo below + // used to guard, which no async port could serve from inside a synchronous + // map. + const records = await handle.nodes.list(); + const bundle = buildSpatialBundle(canvas); const summary = buildSpatialSummary(bundle.spatialNodes, bundle.edges); @@ -311,25 +317,20 @@ export function buildCanvasOutline( // Resolve display labels from the sidecar (topology never carries // them), memoized so a frame referenced as many nodes' parent is read once. - const labelMemo = new Map(); - const memoLabel = (id: string): string | undefined => { - if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); - labelMemo.set(id, l); - return l; - }; + const labelOf = (id: string): string | undefined => + nodeLabel(records.get(id)?.record ?? null); const nodes: CanvasOutlineNode[] = bundle.spatialNodes.map((s) => { const raw = bundle.rawById.get(s.id); - const parentLabel = s.parentId ? memoLabel(s.parentId) : undefined; + const parentLabel = s.parentId ? labelOf(s.parentId) : undefined; const style = opts.includeStyle ? readVisualStyle(raw) : undefined; const out: CanvasOutlineNode = describeNode( - store, { ...spatialNodeInput(s, raw, parentLabel), ...(style ? { style } : {}), }, 'outline', + records.get(s.id)?.record ?? null, ); // The shared builder attaches `summary` (authored abstract) and // `preview` (raw body excerpt) from the sidecar; both are text scan @@ -449,25 +450,23 @@ const DEFAULT_INSPECT_LIMIT = 50; * direction, edgeIds, hops, clusterId) are computed during filtering * and merged into the final result row. */ -export function inspectNodes( +export async function inspectNodes( canvasId: string, args: InspectNodesArgs, -): InspectNodesResult | InspectNodesError { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return { error: `Canvas ${canvasId} not found` }; const bundle = buildSpatialBundle(canvas); + // An unfiltered inspect returns every node, and `labelPattern` has to test + // every candidate, so the candidate set is the Space (§12.6.1). + const records = await handle.nodes.list(); // Display labels come from the sidecar (topology never carries them); // memoize so the `labelPattern` filter and the result map read each once. - const labelMemo = new Map(); - const memoLabel = (id: string): string | undefined => { - if (labelMemo.has(id)) return labelMemo.get(id); - const l = nodeLabel(store, id); - labelMemo.set(id, l); - return l; - }; + const memoLabel = (id: string): string | undefined => + nodeLabel(records.get(id)?.record ?? null); // Per-node derived fields accumulated during filter passes. const derived = new Map>(); @@ -713,9 +712,9 @@ export function inspectNodes( const raw = bundle.rawById.get(s.id); const parentLabel = s.parentId ? memoLabel(s.parentId) : undefined; const base = describeNode( - store, spatialNodeInput(s, raw, parentLabel), 'outline', + records.get(s.id)?.record ?? null, ); // Inspect deliberately omits text hints (`summary` / `preview`); agents // that need text use `get_canvas_outline({ includePreviews: true })` or @@ -815,12 +814,11 @@ export type InspectEdgesError = { error: string }; * `inspect_nodes({ connectedTo }).edgeIds` or directly from a styling * task, so even the unfiltered case is bounded in practice. */ -export function inspectEdges( +export async function inspectEdges( canvasId: string, args: InspectEdgesArgs, -): InspectEdgesResult | InspectEdgesError { - const store = getCanvasStore(canvasId); - const canvas = store.read(); +): Promise { + const canvas = await space(canvasId).read(); if (!canvas) return { error: `Canvas ${canvasId} not found` }; const bundle = buildSpatialBundle(canvas); diff --git a/apps/server/src/modules/canvas/canvas.route.ts b/apps/server/src/modules/canvas/canvas.route.ts index de99b02de..4dd1b1324 100644 --- a/apps/server/src/modules/canvas/canvas.route.ts +++ b/apps/server/src/modules/canvas/canvas.route.ts @@ -2,8 +2,8 @@ // Licensed under the MIT license. import { spawn } from 'node:child_process'; -import { createWriteStream, existsSync, mkdirSync, renameSync } from 'node:fs'; -import { mkdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'; +import { createWriteStream, existsSync, mkdirSync } from 'node:fs'; +import { mkdir, rm, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -47,30 +47,23 @@ 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 { isWorldCanvasId } from '../storage/canvas-dirs.js'; import { space, createSpace, deleteSpace, - getCanvasStore, + stageSpaceImport, getStructuredStore, type CanvasFile, + type NodeContent, + type Space, type UpdateNodeOutcome, updateNode, } from '../storage/index.js'; -import { nodesDir, SPACE_JSON_FILENAME } from '../storage/paths.js'; -import { getWorkspacePath } from '../workspace.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; import type { CanvasNodeType } from '@huabu/shared'; import type { ApiResult, @@ -361,10 +354,10 @@ async function singleArtifactProbe( * callers can rely on identity-based diffing. */ function hydrateOneNode( - store: CanvasStore, node: NodeLike, artifactExists: (key: string) => boolean, - preloaded?: NodeContent | null, + nodeContent: NodeContent | null, + duplicateSidecars: readonly string[], ): NodeLike { const nodeId = typeof node.id === 'string' ? node.id : ''; if (!nodeId) return node; @@ -379,17 +372,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; @@ -411,9 +393,9 @@ function hydrateOneNode( // duplicate. The duplicate set was already populated by the // `readAllNodes()` scan that produced `preloaded`, so this is a cheap // in-memory lookup with no extra disk I/O. - if (store.isDuplicateNode(nodeId)) { + if (duplicateSidecars.length > 0) { data['contentDuplicate'] = true; - data['duplicateFiles'] = store.duplicateNodeFiles(nodeId); + data['duplicateFiles'] = [...duplicateSidecars]; } else { if ('contentDuplicate' in data) { delete data['contentDuplicate']; @@ -512,13 +494,17 @@ function hydrateOneNode( * load on cold cache. */ async function hydrateNodeContent( - store: CanvasStore, + handle: Space, nodes: NodeLike[], ): Promise { // Read sidecars first because they are the source of truth for `src`. // Probe only the keys referenced by artifact-backed nodes; enumerating the // entire scope would make hydration cost grow with unrelated blob count. - const contentByNodeId = await store.readAllNodes(); + const records = await handle.nodes.list(); + const contentByNodeId = new Map(); + for (const [nodeId, snapshot] of records) { + contentByNodeId.set(nodeId, snapshot.record); + } const referenced = new Set(); for (const node of nodes) { const nodeType = typeof node.type === 'string' ? node.type : ''; @@ -531,16 +517,16 @@ async function hydrateNodeContent( const present = referenced.size === 0 ? new Set() - : await space(store.canvasId).blobs.hasMany([...referenced]); + : await handle.blobs.hasMany([...referenced]); const artifactExists = (key: string): boolean => present.has(key); return nodes.map((node) => { const nodeId = typeof node.id === 'string' ? node.id : ''; return hydrateOneNode( - store, node, artifactExists, contentByNodeId.get(nodeId) ?? null, + handle.diskTree?.duplicateSidecars(nodeId) ?? [], ); }); } @@ -886,20 +872,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }>('/:canvasId/nodes/:nodeId/content', async function (request, reply) { const { canvasId, nodeId } = request.params; - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } - // Reconcile the cached node index against disk before this read. - // Only re-scans when warranted: a node already flagged duplicate - // always re-reads (so a hand-resolved duplicate is detected — the - // cheap count probe alone can't see that case), otherwise it falls - // back to the names-only staleness probe. Keeps the common healthy - // read off the full content rescan. - store.revalidateNodeForRead(nodeId); - // Find this node in the persisted canvas state so we know its type // (without it we can't apply the artifact-missing branch). For // nodes that exist in `.md` but not in canvas state we fall back @@ -909,9 +887,12 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { let nodeType = stateNode && typeof stateNode.type === 'string' ? stateNode.type : ''; + // The port's single-node read reconciles the adapter's cached index + // against storage before answering, so a hand-resolved duplicate or an + // external rename is picked up here rather than needing its own probe. let existing: NodeContent | null = null; try { - existing = store.readNode(nodeId); + existing = (await handle.nodes.read(nodeId))?.record ?? null; } catch { existing = null; } @@ -938,13 +919,14 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Reuse the batched hydration helper so single-node and whole- // canvas reads stay in lock-step. const hydrated = hydrateOneNode( - store, { id: nodeId, type: nodeType, data: { ...(stateNode?.data ?? {}) }, }, - await singleArtifactProbe(store.canvasId, existing.src), + await singleArtifactProbe(canvasId, existing.src), + existing, + handle.diskTree?.duplicateSidecars(nodeId) ?? [], ); const data = (hydrated.data ?? {}) as Record; @@ -1014,9 +996,10 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { const { nodeType, trigger, snapshot, previousSnapshot, options } = parsed.data; const dispatcher = getPreprocessDispatcher(); - const store = getCanvasStore(canvasId); - - if (MD_BACKED_NODE_TYPES.has(nodeType) && !store.readNode(nodeId)) { + if ( + MD_BACKED_NODE_TYPES.has(nodeType) && + !(await space(canvasId).nodes.read(nodeId)) + ) { return reply.send({ nodeId, success: false, @@ -1102,8 +1085,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { if (isWorldCanvasId(canvasId)) { await reconcileWorldPortals(); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); @@ -1112,7 +1095,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Hydrate node content from the per-canvas store so clients always // receive fresh markdown bodies. const nodes = canvas.state.nodes as NodeLike[]; - const hydratedNodes = await hydrateNodeContent(store, nodes); + const hydratedNodes = await hydrateNodeContent(handle, nodes); return reply.send({ canvasId: canvas.canvasId, @@ -1588,12 +1571,14 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { Reply: ApiResult; }>('/:canvasId/reveal-nodes', async function (request, reply) { const { canvasId } = request.params; - const store = getCanvasStore(canvasId); - if (!store.read()) { + const handle = space(canvasId); + if (!(await handle.read())) { return reply.code(404).send({ message: 'Canvas not found' }); } - const dir = nodesDir(canvasId); - if (!existsSync(dir)) { + // Disk-only, declared as `reveal-space-folder`: the feature *is* "show me + // this in Finder", so a backend without a folder has nothing to show. + const dir = handle.diskTree?.nodesDirectory(); + if (dir === undefined || !existsSync(dir)) { return reply.code(404).send({ message: 'Nodes folder not found' }); } // Fire-and-forget: `openInFileManager` is best-effort and never @@ -1622,8 +1607,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { } const includeHistory = parsedQuery.data.includeHistory !== 'false'; - const store = getCanvasStore(canvasId); - const canvas = store.read(); + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) { return reply.code(404).send({ message: 'Canvas not found' }); } @@ -1631,7 +1616,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // The Space bundle is a Disk projection (proposal §6.4.3, disposition // A); a portable export generated from records plus reachable blob // references is a separate later design. - const tree = space(canvasId).diskTree; + const tree = handle.diskTree; const canvasDir = tree?.directory(); if (canvasDir === undefined || !existsSync(canvasDir)) { return reply.code(404).send({ message: 'Canvas directory not found' }); @@ -1693,13 +1678,20 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { // Stream the upload to a temp zip file const tmpZip = path.join(tmpdir(), `${createId('import')}.zip`); const targetCanvasId = createId('canvas'); - // Extract into a hidden staging dir so `scanWorkspace()` ignores it - // (it skips dot-prefixed entries) and the as-yet-unrenamed dir cannot - // be picked up by `read()`'s self-heal as a canvas titled ``. - const stagingDir = path.join( - getWorkspacePath(), - `.import-${targetCanvasId}`, - ); + // Where an imported Space lands is the backend's business — the + // staging location, the title-derived directory, the record filename, + // and the index entry are all layout. This route owns the `.huabu.zip` + // format and nothing else (proposal §12.6.2). + const staged = stageSpaceImport(targetCanvasId); + if (!staged) { + // Phrased here only until the capability matrix owns the wording + // (§12.8), so a Disk-only refusal reads the same everywhere. + return reply.code(400).send({ + message: + 'Space bundle import is not available on this storage backend.', + }); + } + const stagingDir = staged.stagingDirectory; let stagingCleanedUp = false; try { await new Promise((resolve, reject) => { @@ -1765,64 +1757,33 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { await writeFile(dest, new Uint8Array(buf)); }); - // Rewrite the topology file so canvasId matches the new directory. - // New bundles carry `space.json`; still accept legacy `canvas.json` - // exports and normalise them to the new name on the way in. - const stagedJsonPath = path.join(stagingDir, SPACE_JSON_FILENAME); - const legacyJsonPath = path.join(stagingDir, 'canvas.json'); - const sourceJsonPath = existsSync(stagedJsonPath) - ? stagedJsonPath - : existsSync(legacyJsonPath) - ? legacyJsonPath - : null; - if (!sourceJsonPath) { - await rm(stagingDir, { recursive: true, force: true }); + const parsed = await staged.readRecord(); + if (!parsed) { + await staged.discard(); stagingCleanedUp = true; return reply.code(400).send({ - message: 'Invalid bundle: missing space.json', + message: 'Invalid bundle: missing Space record', }); } - const raw = await readFile(sourceJsonPath, 'utf-8'); - const parsed = JSON.parse(raw) as CanvasFile; const sourceCanvasId = parsed.canvasId; const importedManifest = manifest as ImportManifest | null; const targetTitle = importedManifest?.title ?? parsed.title ?? 'Imported canvas'; - const finalDirName = suggestCanvasDir(targetTitle, targetCanvasId); - const safeFromTitle = toSafeFilename(targetTitle, targetCanvasId); - const dedupeSuffix = - finalDirName === safeFromTitle - ? '' - : finalDirName.slice(safeFromTitle.length); - const resolvedTitle = - dedupeSuffix === '' ? targetTitle : targetTitle + dedupeSuffix; - - const remapped: CanvasFile = { + + // Artifact URLs are the bundle's own vocabulary, so they are rewritten + // here; where the result is filed is not, so `publish` decides that — + // including the de-duplication suffix it may have to add to the title. + await staged.publish({ ...parsed, canvasId: targetCanvasId, - title: resolvedTitle, + title: targetTitle, state: rewriteCanvasArtifactUrls( parsed.state, sourceCanvasId, targetCanvasId, ), - }; - // Always persist under the new name so the storage layer (which - // addresses `space.json`) can find it; drop a legacy source file. - await writeFile(stagedJsonPath, JSON.stringify(remapped)); - if (sourceJsonPath !== stagedJsonPath) { - await rm(sourceJsonPath, { force: true }); - } - - // Move the staged dir into its final, title-derived location so - // the on-disk basename matches the title and `read()` will not - // self-heal-overwrite the title with the staging dir basename on - // the next access. - const finalDir = path.join(getWorkspacePath(), finalDirName); - renameSync(stagingDir, finalDir); + }); stagingCleanedUp = true; - registerCanvasDir(targetCanvasId, finalDirName, resolvedTitle); - refreshCanvasDirIndex(); const response: ImportCanvasResponse = { canvasId: targetCanvasId, @@ -1862,9 +1823,8 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { }); } - const store = getCanvasStore(canvasId); - const canvas = store.read(); - if (!canvas) { + const handle = space(canvasId); + if (!(await handle.read())) { return reply.code(404).send({ message: 'Canvas not found' }); } @@ -1902,7 +1862,7 @@ const canvasRoutes: FastifyPluginAsync = async (fastify) => { request.raw.on('close', onClose); try { - await searchCanvas(store, parsed.data, writeEvent, abort.signal); + await searchCanvas(handle, parsed.data, writeEvent, abort.signal); } catch (err) { request.log.error({ err, canvasId }, 'Canvas search failed'); writeEvent({ diff --git a/apps/server/src/modules/canvas/external-watcher.test.ts b/apps/server/src/modules/canvas/external-watcher.test.ts index cf3de2474..8203416e8 100644 --- a/apps/server/src/modules/canvas/external-watcher.test.ts +++ b/apps/server/src/modules/canvas/external-watcher.test.ts @@ -68,18 +68,19 @@ vi.mock('../storage/canvas-dirs.js', () => ({ listAllCanvasDirEntries: () => canvasDirs.list(), })); -const canvasStore = vi.hoisted(() => ({ - read: vi.fn(() => ({ state: { nodes: [] } })), +const spaceHandle = vi.hoisted(() => ({ + read: vi.fn(async () => ({ state: { nodes: [] } })), })); -// The facade is stubbed for the store, but the handle helpers must stay the -// real ones: these cases drive `withSpaceDirHandlesReleased` and assert the -// watcher released its handles, which only works if both sides share the one -// module instance that holds the registry. +// The facade is stubbed for the Space handle, but the directory-handle +// helpers must stay the real ones: these cases drive +// `withSpaceDirHandlesReleased` and assert the watcher released its handles, +// which only works if both sides share the one module instance that holds the +// registry. vi.mock('../storage/index.js', async () => { const handles = await import('../storage/backends/disk/space-dir-handles.js'); return { - getCanvasStore: () => canvasStore, + space: () => spaceHandle, registerSpaceDirHandleOwner: handles.registerSpaceDirHandleOwner, withSpaceDirHandlesReleased: handles.withSpaceDirHandlesReleased, }; @@ -146,7 +147,7 @@ beforeEach(() => { isFile: () => true, isDirectory: () => false, }); - canvasStore.read.mockClear(); + spaceHandle.read.mockClear(); canvasDirs.list.mockReturnValue([]); state.configured = true; }); @@ -260,15 +261,17 @@ describe('openExternalNoteSession', () => { ]); const first = await openExternalNoteSession('canvas-a', vi.fn()); + const afterFirst = spaceHandle.read.mock.calls.length; const second = await openExternalNoteSession('canvas-a', vi.fn()); const readPaths = fileIO.readFile.mock.calls.map(([filePath]) => filePath); expect( readPaths.filter((filePath) => filePath.endsWith('.md')), ).toHaveLength(2); - expect( - readPaths.filter((filePath) => filePath.endsWith('space.json')), - ).toHaveLength(1); + // Topology comes from the Space record now, not a file beside `nodes/`, + // so the invariant is counted on the port: the second subscriber shares + // the first's scan and pays only for its own snapshot. + expect(spaceHandle.read.mock.calls.length - afterFirst).toBe(1); expect(fileIO.readdir).toHaveBeenCalledTimes(1); expect(first.snapshot).toHaveLength(2); @@ -552,7 +555,7 @@ describe('openExternalNoteSession', () => { emitNativeWatcherEvent('later.md'); await vi.waitFor(() => { - expect(canvasStore.read).toHaveBeenCalled(); + expect(spaceHandle.read).toHaveBeenCalled(); }); expect(listener).not.toHaveBeenCalled(); @@ -687,7 +690,7 @@ describe('native note events', () => { // A repeat observation replaces the entry instead of duplicating it. emitNativeWatcherEvent('later.md'); await vi.waitFor(() => { - expect(canvasStore.read.mock.calls.length).toBeGreaterThan(1); + expect(spaceHandle.read.mock.calls.length).toBeGreaterThan(1); }); expect(events).toHaveLength(1); diff --git a/apps/server/src/modules/canvas/external-watcher.ts b/apps/server/src/modules/canvas/external-watcher.ts index fc37894a7..c20430521 100644 --- a/apps/server/src/modules/canvas/external-watcher.ts +++ b/apps/server/src/modules/canvas/external-watcher.ts @@ -33,9 +33,8 @@ import path from 'node:path'; import { getLogger } from '../../utils/logger.js'; import { parseFrontmatter } from '../../utils/markdown-frontmatter.js'; import { listAllCanvasDirEntries } from '../storage/canvas-dirs.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import { registerSpaceDirHandleOwner } from '../storage/index.js'; -import { SPACE_JSON_FILENAME } from '../storage/paths.js'; import { getWorkspacePath, isWorkspaceConfigured } from '../workspace.js'; import type { CanvasFile } from '../storage/index.js'; @@ -130,19 +129,24 @@ function noteIdsFromCanvas(canvas: CanvasFile | null): Set { return ids; } -function canvasNoteIds(canvasId: string): Set { - return noteIdsFromCanvas(getCanvasStore(canvasId).read()); +async function canvasNoteIds(canvasId: string): Promise> { + return noteIdsFromCanvas(await space(canvasId).read()); } +/** + * The scan's view of which notes the Space already knows. + * + * Read through the port rather than off the record file beside `nodes/`. The + * path read was equivalent only because Disk keeps the two together, and this + * question — what does the Space contain — is one every backend answers. + * Failure degrades to "knows nothing", as before: a scan that cannot read + * topology surfaces every file rather than silently hiding some. + */ async function readInitialCanvasNoteIds( - nodesPath: string, + canvasId: string, ): Promise> { try { - const raw = await readFile( - path.join(path.dirname(nodesPath), SPACE_JSON_FILENAME), - 'utf8', - ); - return noteIdsFromCanvas(JSON.parse(raw) as CanvasFile); + return await canvasNoteIds(canvasId); } catch { return new Set(); } @@ -195,8 +199,16 @@ function forgetItem(session: ActiveSpaceWatch, relativePath: string): void { emit(session, { type: 'removed', data: { relativePath } }); } -function snapshotOf(session: ActiveSpaceWatch): ExternalNoteItem[] { - const known = canvasNoteIds(session.canvasId); +/** + * `known` is passed in rather than read here because this must stay + * synchronous: it both reads and prunes `pendingItems`, and the caller relies + * on registering its listener and taking the snapshot without an await + * between them, so no event can slip through. + */ +function snapshotOf( + session: ActiveSpaceWatch, + known: ReadonlySet, +): ExternalNoteItem[] { const out: ExternalNoteItem[] = []; for (const [rel, item] of session.pendingItems) { if (item.noteId && known.has(item.noteId)) { @@ -228,7 +240,7 @@ function scheduleNodeEvent(session: ActiveSpaceWatch, basename: string): void { .then(async (fileStat) => { if (!fileStat.isFile()) return; const item = await buildItem(absPath, relativePath, () => - Promise.resolve(canvasNoteIds(session.canvasId)), + canvasNoteIds(session.canvasId), ); if (!item || !isSessionCurrent(session, stamp)) return; recordItem(session, item); @@ -674,7 +686,7 @@ async function runInitialScan(session: ActiveSpaceWatch): Promise { let topology: Promise> | null = null; const knownNoteIds = (): Promise> => - (topology ??= readInitialCanvasNoteIds(session.nodesPath)); + (topology ??= readInitialCanvasNoteIds(session.canvasId)); let nextIndex = 0; const worker = async (): Promise => { @@ -787,11 +799,13 @@ export async function openExternalNoteSession( await ensureInitialScan(active); } + const known = await canvasNoteIds(active.canvasId); + // Registering the listener and reading the snapshot must stay in one // synchronous block so no event can slip between them. if (released || !isSessionCurrent(active)) return { snapshot: [], close }; active.listeners.add(listener); - return { snapshot: snapshotOf(active), close }; + return { snapshot: snapshotOf(active, known), close }; } /** Remove and return a pending item — used by the import endpoint. */ diff --git a/apps/server/src/modules/canvas/import-node-src.test.ts b/apps/server/src/modules/canvas/import-node-src.test.ts index 731608357..5dc22e475 100644 --- a/apps/server/src/modules/canvas/import-node-src.test.ts +++ b/apps/server/src/modules/canvas/import-node-src.test.ts @@ -43,6 +43,7 @@ beforeEach(() => { 'c-web-remote', 'c-web-data', 'c-web-merge-local', + 'c-web-merge-multiple', 'c-web-merge-remote', 'c-image-local', 'c-image-reentered', @@ -82,19 +83,25 @@ function firstPatchedSrc(commands: CanvasCommand[]): string | undefined { /** Seed a Space with one existing web node for merge-path tests. */ function seedWebNode(canvasId: string, nodeId: string, src: string): void { + seedWebNodes(canvasId, [{ nodeId, src }]); +} + +/** Seed a Space with existing web nodes for merge-path tests. */ +function seedWebNodes( + canvasId: string, + nodes: readonly { nodeId: string; src: string }[], +): void { getCanvasStore(canvasId).write({ canvasId, title: null, version: 1, state: { - nodes: [ - { - id: nodeId, - type: 'web', - position: { x: 0, y: 0 }, - data: { label: 'Existing page', src }, - }, - ], + nodes: nodes.map(({ nodeId, src }) => ({ + id: nodeId, + type: 'web', + position: { x: 0, y: 0 }, + data: { label: 'Existing page', src }, + })), edges: [], }, createdAt: Date.now(), @@ -105,7 +112,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', @@ -125,7 +131,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… @@ -140,7 +146,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[] = [ @@ -156,7 +161,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); @@ -164,7 +169,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[] = [ @@ -180,13 +184,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[] = [ @@ -202,7 +205,7 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstSrc(out)).toBe(dataUrl); }); @@ -210,7 +213,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', @@ -223,7 +225,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$/); @@ -237,7 +239,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[] = [ { @@ -246,16 +247,48 @@ describe('importForeignNodeSources — web nodes', () => { }, ]; - const out = await importForeignNodeSources(store, canvasId, commands); + const out = await importForeignNodeSources(canvasId, commands); expect(firstPatchedSrc(out)).toBe(remote); }); + + it('normalizes every entry in a multi-node MERGE_NODE_DATA command', async () => { + const canvasId = 'c-web-merge-multiple'; + const firstNodeId = 'node-web-merge-first'; + const secondNodeId = 'node-web-merge-second'; + seedWebNodes(canvasId, [ + { nodeId: firstNodeId, src: 'https://example.com/first' }, + { nodeId: secondNodeId, src: 'https://example.com/second' }, + ]); + const firstUpload = stageUpload(canvasId, 'first.html', '

first

'); + const secondUpload = stageUpload(canvasId, 'second.html', '

second

'); + + const out = await importForeignNodeSources(canvasId, [ + { + type: 'MERGE_NODE_DATA', + patches: [ + { nodeId: firstNodeId, patch: { src: 'upload/first.html' } }, + { nodeId: secondNodeId, patch: { src: 'upload/second.html' } }, + ], + }, + ]); + const command = out[0]; + if (command?.type !== 'MERGE_NODE_DATA') { + throw new Error('Expected MERGE_NODE_DATA output'); + } + + expect(command.patches.map((entry) => entry.patch.src)).toEqual([ + expect.stringMatching(/^artifact-[^/]+\.html$/), + expect.stringMatching(/^artifact-[^/]+\.html$/), + ]); + expect(existsSync(firstUpload)).toBe(false); + expect(existsSync(secondUpload)).toBe(false); + }); }); 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[] = [ @@ -271,7 +304,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(); @@ -281,7 +314,6 @@ 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 = diskDirOf(canvasId); const artifactsDir = path.join(spaceDir, '.artifacts'); mkdirSync(artifactsDir, { recursive: true }); @@ -306,7 +338,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 77f39c27f..b51faebaa 100644 --- a/apps/server/src/modules/canvas/import-node-src.ts +++ b/apps/server/src/modules/canvas/import-node-src.ts @@ -44,8 +44,6 @@ import { } from '../agent/tools/handlers/fs-sandbox.js'; import { space } from '../storage/index.js'; -import type { CanvasStore } from '../storage/index.js'; - const log = getLogger('canvas.import-node-src'); /** Hidden RFS scratch dir; only files here are reclaimed (move semantics). */ @@ -137,26 +135,28 @@ 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. - 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') { - typeById.set(n.id, n.type); + let typeByIdPromise: Promise> | null = null; + const nodeType = async (nodeId: string): Promise => { + if (!typeByIdPromise) { + typeByIdPromise = (async () => { + const byId = new Map(); + const canvas = await space(canvasId).read(); + for (const raw of canvas?.state.nodes ?? []) { + const n = raw as { id?: unknown; type?: unknown }; + if (typeof n.id === 'string' && typeof n.type === 'string') { + byId.set(n.id, n.type); + } } - } + return byId; + })(); } - return typeById.get(nodeId) ?? ''; + return (await typeByIdPromise).get(nodeId) ?? ''; }; const out: CanvasCommand[] = []; @@ -168,7 +168,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, @@ -189,10 +188,9 @@ export async function importForeignNodeSources( if (cmd.type === 'MERGE_NODE_DATA') { const patches = await Promise.all( cmd.patches.map(async (entry) => { - const mode = srcNormalizeMode(nodeType(entry.nodeId)); + const mode = srcNormalizeMode(await nodeType(entry.nodeId)); if (!mode) return entry; const key = await resolveImportedSrc( - store, canvasId, entry.patch?.['src'], mode.allowRemoteDownload, @@ -223,7 +221,6 @@ export async function importForeignNodeSources( * preserved in place and left unchanged. */ async function resolveImportedSrc( - store: CanvasStore, canvasId: string, raw: unknown, allowRemoteDownload: boolean, @@ -249,7 +246,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. @@ -296,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 { @@ -310,7 +307,7 @@ async function copyToArtifact( const id = createId('artifact'); const key = `${id}${ext}`; const buffer = await readFile(absPath); - await space(store.canvasId).blobs.put(key, buffer); + await space(canvasId).blobs.put(key, buffer); // Move semantics: reclaim RFS scratch uploads once they are safely // stored. Never delete user node files or other canvas content — @@ -335,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 { @@ -366,7 +363,7 @@ async function downloadToArtifact( } const ext = pickDownloadExt(pathname, contentType); const key = `${createId('artifact')}${ext}`; - await space(store.canvasId).blobs.put(key, buffer); + await space(canvasId).blobs.put(key, buffer); return key; } catch (err) { log.warn({ err, url }, 'Failed to download online node src into artifacts'); diff --git a/apps/server/src/modules/canvas/node-neighbourhood.ts b/apps/server/src/modules/canvas/node-neighbourhood.ts index 350f3d78b..710d5e8d4 100644 --- a/apps/server/src/modules/canvas/node-neighbourhood.ts +++ b/apps/server/src/modules/canvas/node-neighbourhood.ts @@ -46,7 +46,7 @@ import { renderNodes, } from '../agent/conversation/prompt/node-element.js'; import { buildAgentNodePreview } from '../agent/node-ref.js'; -import { getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; import type { AgentNodePreview } from '../agent/node-ref.js'; import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; @@ -67,39 +67,39 @@ import type { CanvasNodeType, SpatialNode } from '@huabu/shared'; * Owns the preview-extraction policy. Forwards each node through the * shared {@link extractAgentNodePreview} ladder * (`summary > content[:120] > src`) with two inputs merged in one - * pass: the on-disk frontmatter (via `readNode` — canonical for note - * nodes whose body lives in `nodes/.md`) and the inline - * `data.content` / `data.src` (text-on-canvas nodes whose body never - * touches disk). Per-node disk reads are memoized. + * pass: the stored node record (canonical for note nodes whose body lives + * outside the topology) and the inline `data.content` / `data.src` + * (text-on-canvas nodes whose body is never a separate record). + * + * Records are read once, up front, for the whole Space. The neighbourhood + * is a subset, so this reads more than the old lazy per-node path did on a + * sparse canvas — but a lazy read cannot cross an async port without making + * the pure walk below async too, and the walk already visits every node to + * build the spatial bundle (§12.6.1). */ -export function getNodeNeighbourhood( +export async function getNodeNeighbourhood( canvasId: string, anchorNodeId: string, -): NodeNeighbourhoodContext | null { - const canvas = getCanvasStore(canvasId).read(); +): Promise { + const handle = space(canvasId); + const canvas = await handle.read(); if (!canvas) return null; const bundle = buildSpatialBundle(canvas); const target = bundle.spatialNodes.find((n) => n.id === anchorNodeId); if (!target) return null; - const store = getCanvasStore(canvasId); - const cache = new Map(); + const records = await handle.nodes.list(); // One assembler for every neighbour: the node carries whatever the spatial // bundle knows (id / type; its `data.label` is always empty), and - // `describeNode` fills label + body from the sidecar, then derives the + // `describeNode` fills label + body from the record, then derives the // `file=` path, preview line, and `rev` token (in lock-step with the RFS - // `ETag`). Memoized so a node referenced twice is read once. - const describe = (n: SpatialNode): AgentNodePreview => { - const hit = cache.get(n.id); - if (hit) return hit; - const preview = describeNode( - store, + // `ETag`). + const describe = (n: SpatialNode): AgentNodePreview => + describeNode( { id: n.id, type: n.type, ...(n.label ? { label: n.label } : {}) }, 'preview', + records.get(n.id)?.record ?? null, ); - cache.set(n.id, preview); - return preview; - }; return buildNodeNeighbourhoodContext( target, diff --git a/apps/server/src/modules/canvas/node-prompt.test.ts b/apps/server/src/modules/canvas/node-prompt.test.ts index e8f309348..8c509d479 100644 --- a/apps/server/src/modules/canvas/node-prompt.test.ts +++ b/apps/server/src/modules/canvas/node-prompt.test.ts @@ -6,42 +6,43 @@ * ({@link describeNode} / {@link nodeLabel} / {@link renderNodes}). * * Focus: the ONE rule — the caller's own fields win, anything missing is - * filled from the on-disk sidecar — plus the two agent-facing levels + * filled from the stored record — plus the two agent-facing levels * (`preview` / `outline`), `rev` presence, the summary/preview split, the - * null-store degenerate path, and the `` rendering. + * no-record degenerate path, and the `` rendering. */ import { describe, expect, it } from 'vitest'; import { describeNode, nodeLabel, renderNodes } from './node-prompt.js'; -import type { CanvasStore, NodeContent } from '../storage/canvas-store.js'; +import type { NodeContent } from '../storage/index.js'; -/** Minimal stub: only `readNode` is exercised by this module. */ -function stubStore( - nodes: Record | null>, -): CanvasStore { +/** One stored record, as a caller would have already read it. */ +function record( + id: string, + fields: Partial | null, +): NodeContent | null { + if (fields === null) return null; return { - readNode(id: string): NodeContent | null { - const n = nodes[id]; - if (n == null) return null; - return { - nodeId: id, - type: 'note', - label: null, - content: '', - ...n, - } as NodeContent; - }, - } as unknown as CanvasStore; + nodeId: id, + type: 'note', + label: null, + content: '', + ...fields, + } as NodeContent; } describe('describeNode — preview level', () => { it('fills label + body from the sidecar when the caller has none', () => { - const store = stubStore({ - n1: { label: 'My Note', content: 'Hello body', summary: 'Abstract' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'My Note', + content: 'Hello body', + summary: 'Abstract', + }), + ); expect(node.label).toBe('My Note'); // filename is derived from the (sidecar) label — the real on-disk path, @@ -53,19 +54,22 @@ describe('describeNode — preview level', () => { }); it("prefers the caller's own field over the sidecar (own wins)", () => { - const store = stubStore({ n1: { label: 'Sidecar Label', content: 'x' } }); const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Wire Label' }, 'preview', + record('n1', { label: 'Sidecar Label', content: 'x' }), ); expect(node.label).toBe('Wire Label'); expect(node.filename).toBe('nodes/Wire Label.md'); }); it('omits rev / summary / preview for a node with no body or summary', () => { - const store = stubStore({ n1: { label: 'Empty' } }); // content '' by default - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + // content '' by default + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { label: 'Empty' }), + ); expect(node.label).toBe('Empty'); expect(node.rev).toBeUndefined(); expect(node.summary).toBeUndefined(); @@ -73,54 +77,54 @@ describe('describeNode — preview level', () => { }); it('emits summary and preview as INDEPENDENT fields', () => { - const store = stubStore({ - n1: { label: 'L', summary: 'The abstract', content: 'The full body' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'L', + summary: 'The abstract', + content: 'The full body', + }), + ); expect(node.summary).toBe('The abstract'); expect(node.preview).toBe('The full body'); }); it('hashes rev from a source-backed node with no body', () => { - const store = stubStore({ - n1: { type: 'image', label: 'Pic', src: 'artifacts/a.png' }, - }); - const node = describeNode(store, { id: 'n1', type: 'image' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'image' }, + 'preview', + record('n1', { type: 'image', label: 'Pic', src: 'artifacts/a.png' }), + ); expect(typeof node.rev).toBe('string'); expect(node.preview).toBeUndefined(); // src is not a content preview }); - it('with a null store, uses only the caller-supplied fields', () => { + it('with no stored record, uses only the caller-supplied fields', () => { const node = describeNode( - null, { id: 'n1', type: 'note', label: 'L', content: 'Body' }, 'preview', + null, ); expect(node.label).toBe('L'); expect(node.preview).toBe('Body'); expect(typeof node.rev).toBe('string'); }); - it('with meta=null, forces "no sidecar" even when a store is passed', () => { - const store = stubStore({ n1: { label: 'Sidecar', content: 'x' } }); + it('treats a null record as "this node has none"', () => { const node = describeNode( - store, { id: 'n1', type: 'note', label: 'Own' }, 'preview', null, ); expect(node.label).toBe('Own'); - expect(node.preview).toBeUndefined(); // sidecar body ignored + expect(node.preview).toBeUndefined(); }); }); describe('describeNode — outline level', () => { it('layers spatial metadata on top of the sidecar-sourced fields', () => { - const store = stubStore({ - n1: { label: 'Node', content: 'Body', summary: 'Sum' }, - }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -130,6 +134,7 @@ describe('describeNode — outline level', () => { style: { color: 'red' }, }, 'outline', + record('n1', { label: 'Node', content: 'Body', summary: 'Sum' }), ); expect(node.position).toEqual({ x: 10, y: 20 }); expect(node.size).toEqual({ width: 30, height: 40 }); @@ -144,9 +149,7 @@ describe('describeNode — outline level', () => { }); it('carries an explicit absolutePosition distinct from parent-local position', () => { - const store = stubStore({ n1: { label: 'Node' } }); const node = describeNode( - store, { id: 'n1', type: 'note', @@ -156,6 +159,7 @@ describe('describeNode — outline level', () => { parentFrame: { id: 'f1', label: 'Frame' }, }, 'outline', + record('n1', { label: 'Node' }), ); expect(node.position).toEqual({ x: 50, y: 60 }); expect(node.absolutePosition).toEqual({ x: 1050, y: 560 }); @@ -163,15 +167,15 @@ describe('describeNode — outline level', () => { }); describe('nodeLabel', () => { - it('returns the sidecar label', () => { - const store = stubStore({ n1: { label: 'Frame Title' } }); - expect(nodeLabel(store, 'n1')).toBe('Frame Title'); + it('returns the record label', () => { + expect(nodeLabel(record('n1', { label: 'Frame Title' }))).toBe( + 'Frame Title', + ); }); - it('returns undefined when the node has no sidecar or no label', () => { - const store = stubStore({ n1: null, n2: { label: null } }); - expect(nodeLabel(store, 'n1')).toBeUndefined(); - expect(nodeLabel(store, 'n2')).toBeUndefined(); + it('returns undefined when there is no record or no label', () => { + expect(nodeLabel(record('n1', null))).toBeUndefined(); + expect(nodeLabel(record('n2', { label: null }))).toBeUndefined(); }); }); @@ -205,10 +209,15 @@ describe('renderNodes', () => { }); it('renders the summary/preview split from describeNode end-to-end', () => { - const store = stubStore({ - n1: { label: 'Risks', summary: 'FX exposure', content: 'Full body' }, - }); - const node = describeNode(store, { id: 'n1', type: 'note' }, 'preview'); + const node = describeNode( + { id: 'n1', type: 'note' }, + 'preview', + record('n1', { + label: 'Risks', + summary: 'FX exposure', + content: 'Full body', + }), + ); const xml = renderNodes([node]); expect(xml).toBe( '; + /** Omit the sidecar entirely; the node exists in topology only. */ + readonly sidecar?: false; +} + +interface SeedEdge { + readonly id: string; + readonly source: string; + readonly target: string; + readonly label?: string; +} + +let workspacePath: string; +let app: FastifyInstance; + +function freshWorkspace(): string { + const root = mkdtempSync(path.join(tmpdir(), 'huabu-portable-reads-')); + setWorkspacePath(root); + resetStorageCache(); + return root; +} + +async function buildApp(): Promise { + const instance = fastify(); + await instance.register(multipart); + await instance.register(canvasRoutes, { prefix: '/canvas' }); + await instance.ready(); + return instance; +} + +function topologyNode(node: SeedNode): Record { + return { + id: node.id, + type: node.type, + position: node.position ?? { x: 0, y: 0 }, + data: { label: node.label, ...(node.data ?? {}) }, + }; +} + +function nodeRecord(node: SeedNode): NodeContent { + return { + nodeId: node.id, + type: node.type, + label: node.label, + content: node.content ?? '', + }; +} + +/** Create a Space and install one topology + sidecar generation through the port. */ +async function seedSpace( + canvasId: string, + title: string | null, + nodes: readonly SeedNode[] = [], + edges: readonly SeedEdge[] = [], +): Promise { + const created = await createSpace(canvasId, title); + if (!created.ok) throw new Error(`seed failed for ${canvasId}`); + return writeTopology(canvasId, nodes, edges); +} + +/** Replace a Space's topology and sidecars, whatever its current version. */ +async function writeTopology( + canvasId: string, + nodes: readonly SeedNode[], + edges: readonly SeedEdge[] = [], +): Promise { + const handle = space(canvasId); + const current = await handle.read(); + if (!current) throw new Error(`no Space record for ${canvasId}`); + const nextRecord: CanvasFile = { + ...current, + version: current.version + 1, + updatedAt: Date.now(), + state: { + ...current.state, + nodes: nodes.map(topologyNode), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + ...(edge.label === undefined ? {} : { data: { label: edge.label } }), + })), + }, + }; + const result = await handle.write({ + expectedVersion: current.version, + nextRecord, + nodeMutations: nodes + .filter((node) => node.sidecar !== false) + .map((node) => ({ + kind: 'put' as const, + nodeId: node.id, + record: nodeRecord(node), + })), + }); + if (!result.ok) throw new Error(`seed write failed: ${result.reason}`); + return nextRecord; +} + +/** + * Disk's own directory for a Space. + * + * The tests reach for it to damage a Space the way a user with a file manager + * would — which is exactly the capability `diskTree` exists to name, and the + * only reason a test may know a Space is a folder. + */ +function spaceDirectory(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('expected a Disk-backed Space'); + return tree.directory(); +} + +function spaceNodesDirectory(canvasId: string): string { + const tree = space(canvasId).diskTree; + if (!tree) throw new Error('expected a Disk-backed Space'); + return tree.nodesDirectory(); +} + +/** Hand-write a sidecar the way a user editing files outside the app would. */ +function writeSidecarByHand( + canvasId: string, + filename: string, + body: string, +): void { + const dir = spaceNodesDirectory(canvasId); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, filename), body, 'utf8'); + resetStorageCache(); +} + +/** Overwrite a Space record on disk, bypassing the port's version check. */ +function writeRecordByHand(canvasId: string, record: unknown): void { + writeFileSync( + path.join(spaceDirectory(canvasId), 'space.json'), + JSON.stringify(record, null, 2), + 'utf8', + ); + resetStorageCache(); +} + +function multipartBody( + filename: string, + body: Buffer, +): { payload: Buffer; headers: Record } { + const boundary = '----huabu-portable-reads'; + const head = Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: application/zip\r\n\r\n`, + ); + const tail = Buffer.from(`\r\n--${boundary}--\r\n`); + return { + payload: Buffer.concat([head, body, tail]), + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + }; +} + +/** Build a Space bundle by hand, so an older on-the-wire shape can be replayed. */ +async function makeBundle(entries: Record): Promise { + const archive = archiver('zip', { zlib: { level: 0 } }); + const chunks: Buffer[] = []; + archive.on('data', (chunk: Buffer) => chunks.push(chunk)); + const finished = new Promise((resolve, reject) => { + archive.on('end', () => resolve()); + archive.on('error', reject); + }); + for (const [name, content] of Object.entries(entries)) { + archive.append(content, { name }); + } + await archive.finalize(); + await finished; + return Buffer.concat(chunks); +} + +/** Read an NDJSON search response back into its events. */ +function ndjson(payload: string): Record[] { + return payload + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as Record); +} + +beforeEach(async () => { + workspacePath = freshWorkspace(); + await getStructuredStore().spaces().ensureWorld(); + app = await buildApp(); +}); + +afterEach(async () => { + await app.close(); + resetStorageCache(); + rmSync(workspacePath, { recursive: true, force: true }); +}); + +// ─── 1. The four node-read shapes ─────────────────────────────────────────── + +describe('node reads through the port', () => { + it('serves the same records and revisions through read, readMany, list, and stream', async () => { + await seedSpace('canvas-shapes', 'Shapes', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'alpha body' }, + { id: 'node-b', type: 'note', label: 'Beta', content: 'beta body' }, + { id: 'node-c', type: 'text', label: 'Gamma', content: 'gamma body' }, + ]); + const nodes = space('canvas-shapes').nodes; + + const listed = await nodes.list(); + const streamed = new Map(); + const settled = await nodes.stream((snapshot) => { + streamed.set(snapshot.record.nodeId, snapshot); + }); + const selected = await nodes.readMany(['node-a', 'node-c']); + const single = await nodes.read('node-a'); + + expect([...listed.keys()].sort()).toEqual(['node-a', 'node-b', 'node-c']); + expect(settled).toEqual(listed); + expect(streamed.size).toBe(3); + expect(single).toEqual(listed.get('node-a')); + expect(selected.get('node-a')).toEqual(listed.get('node-a')); + expect(selected.get('node-c')).toEqual(listed.get('node-c')); + // The revision is the port's own opaque token; the same record must not + // produce two of them depending on which shape asked for it. + expect(single?.revision).toBe(listed.get('node-a')?.revision); + }); + + it('treats an absent id in readMany as a missing key and collapses repeats', async () => { + await seedSpace('canvas-select', 'Select', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'a' }, + ]); + + const selected = await space('canvas-select').nodes.readMany([ + 'node-a', + 'node-a', + 'node-missing', + ]); + + expect(selected.size).toBe(1); + expect(selected.has('node-missing')).toBe(false); + expect(await space('canvas-select').nodes.read('node-missing')).toBeNull(); + }); + + it('stops a streamed scan early when the caller aborts, and still settles', async () => { + // More nodes than the adapter's read concurrency, so aborting on the first + // delivery still leaves work the scan can decline to do. + const total = 128; + await seedSpace( + 'canvas-abort', + 'Abort', + Array.from({ length: total }, (_, i) => ({ + id: `node-${i}`, + type: 'note', + label: `Node ${i}`, + content: `body ${i}`, + })), + ); + + const signal = { aborted: false }; + const seen: string[] = []; + const settled = await space('canvas-abort').nodes.stream( + (snapshot) => { + seen.push(snapshot.record.nodeId); + signal.aborted = true; + }, + { signal }, + ); + + // The promise still settles — an aborted scan must not leak a pending + // read — but its map is partial by definition. + expect(seen.length).toBeGreaterThan(0); + expect(settled.size).toBeLessThan(total); + expect(settled.size).toBe(seen.length); + }); + + it('recovers a hand-broken sidecar identically through read and list', async () => { + await seedSpace('canvas-broken', 'Broken', [ + { id: 'node-ok', type: 'note', label: 'Fine', content: 'fine' }, + ]); + writeSidecarByHand( + 'canvas-broken', + 'node-damaged.md', + '---\nlabel: [unterminated\n---\nthe body survives\n', + ); + + const nodes = space('canvas-broken').nodes; + const single = await nodes.read('node-damaged'); + const listed = await nodes.list(); + + // Broken YAML is dropped, the markdown body is kept, and both shapes + // report the node the same way. + expect(single?.record.content.trim()).toBe('the body survives'); + expect(single?.record.label).toBeNull(); + expect(listed.get('node-damaged')).toEqual(single); + }); + + it('rejects both collection scans when a record cannot be retrieved at all', async () => { + await seedSpace('canvas-unreadable', 'Unreadable', [ + { id: 'node-ok', type: 'note', label: 'Fine', content: 'fine' }, + ]); + // A directory where a sidecar should be: reachable-but-unreadable, which + // is the environmental failure the port says must never look like absence. + mkdirSync( + path.join(spaceNodesDirectory('canvas-unreadable'), 'node-blocked.md'), + { recursive: true }, + ); + resetStorageCache(); + + const nodes = space('canvas-unreadable').nodes; + await expect(nodes.list()).rejects.toThrow(); + // The streamed shape answers the same way, so a caller cannot pick the + // scan that hides the failure. + await expect(nodes.stream(() => {})).rejects.toThrow(); + }); +}); + +// ─── 2. GET /canvas/:id ───────────────────────────────────────────────────── + +describe('GET /canvas/:canvasId', () => { + it('hydrates node bodies that live only in the sidecars', async () => { + await seedSpace('canvas-get', 'Get', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'sidecar body' }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-get', + }); + + expect(response.statusCode).toBe(200); + const node = response.json().state.nodes[0]; + expect(node.data.content).toBe('sidecar body'); + // The body must not have been round-tripped into the topology record. + const record = await space('canvas-get').read(); + expect(JSON.stringify(record?.state.nodes)).not.toContain('sidecar body'); + }); + + it('still serves a Space whose sidecar frontmatter a user broke', async () => { + await seedSpace('canvas-get-broken', 'Get broken', [ + { id: 'node-damaged', type: 'note', label: 'Damaged', sidecar: false }, + ]); + writeSidecarByHand( + 'canvas-get-broken', + 'node-damaged.md', + '---\nlabel: [unterminated\n---\nstill readable\n', + ); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-get-broken', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().state.nodes[0].data.content).toContain( + 'still readable', + ); + }); + + it('answers 404 for a Space that does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-absent', + }); + + expect(response.statusCode).toBe(404); + }); +}); + +// ─── 3. GET /canvas/:id/preview-scene ─────────────────────────────────────── + +describe('GET /canvas/:canvasId/preview-scene', () => { + it('projects records and topology into one bounded scene', async () => { + await seedSpace( + 'canvas-preview', + 'Preview', + [ + { + id: 'node-a', + type: 'note', + label: 'Alpha', + content: '# Heading\n\nSome **preview** text.', + position: { x: 10, y: 20 }, + }, + { + id: 'node-b', + type: 'note', + label: 'Beta', + content: 'beta body', + position: { x: 300, y: 20 }, + }, + ], + [{ id: 'edge-1', source: 'node-a', target: 'node-b', label: 'links' }], + ); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-preview/preview-scene', + }); + + expect(response.statusCode).toBe(200); + const scene = response.json(); + expect(scene.nodes).toHaveLength(2); + expect(scene.nodes[0]).toMatchObject({ + id: 'node-a', + kind: 'content', + x: 10, + y: 20, + }); + // Markdown is stripped for the preview text, and it comes from the record. + expect(scene.nodes[0].previewText).toContain('Some preview text'); + expect(scene.edges).toEqual([ + expect.objectContaining({ id: 'edge-1', label: 'links' }), + ]); + expect(scene.truncated).toEqual({ nodes: false, edges: false }); + }); + + it('renders a damaged sidecar instead of refusing the whole projection', async () => { + // The behaviour this change set moved on purpose: the preview used to read + // sidecars strictly and answer 422 when one failed to parse. It now reads + // the same lenient collection the Space's own view does. + await seedSpace('canvas-preview-damaged', 'Damaged preview', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'alpha' }, + { id: 'node-damaged', type: 'note', label: 'Damaged', sidecar: false }, + ]); + writeSidecarByHand( + 'canvas-preview-damaged', + 'node-damaged.md', + '---\nkeywords: [oops\n---\nrecovered body\n', + ); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-preview-damaged/preview-scene', + }); + + expect(response.statusCode).toBe(200); + const ids = response.json().nodes.map((node: { id: string }) => node.id); + expect(ids).toEqual(['node-a', 'node-damaged']); + }); + + it('falls back to topology data for a node with no sidecar at all', async () => { + await seedSpace('canvas-preview-bare', 'Bare preview', [ + { + id: 'node-bare', + type: 'note', + label: 'Bare', + sidecar: false, + data: { content: 'topology fallback' }, + }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-preview-bare/preview-scene', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().nodes[0]).toMatchObject({ + id: 'node-bare', + label: 'Bare', + previewText: 'topology fallback', + }); + }); + + it('keeps 422 for a malformed Space record', async () => { + await seedSpace('canvas-preview-malformed', 'Malformed', []); + writeRecordByHand('canvas-preview-malformed', { + canvasId: 'canvas-preview-malformed', + title: 'Malformed', + version: 1, + createdAt: 1, + updatedAt: 1, + state: { nodes: 'not an array', edges: [] }, + }); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-preview-malformed/preview-scene', + }); + + expect(response.statusCode).toBe(422); + expect(response.json().message).toContain('malformed'); + }); + + it('answers 404 for an absent Space and for the World Space', async () => { + const worldId = await getStructuredStore().spaces().worldId(); + + const absent = await app.inject({ + method: 'GET', + url: '/canvas/canvas-absent/preview-scene', + }); + const world = await app.inject({ + method: 'GET', + url: `/canvas/${worldId}/preview-scene`, + }); + + expect(absent.statusCode).toBe(404); + expect(world.statusCode).toBe(404); + }); +}); + +// ─── 4. Duplicate Space directories ───────────────────────────────────────── + +describe('two directories claiming one Space', () => { + it('fails every catalogue read loudly and names both directories', async () => { + await seedSpace('canvas-dupe', 'Duplicated', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'a' }, + ]); + cpSync( + spaceDirectory('canvas-dupe'), + path.join(workspacePath, 'Duplicated copy'), + { recursive: true }, + ); + refreshCanvasDirIndex(); + + const listed = await app.inject({ method: 'GET', url: '/canvas' }); + + expect(listed.statusCode).toBe(500); + await expect(getStructuredStore().spaces().list()).rejects.toThrow( + /duplicate directories.*Duplicated.*Duplicated copy/s, + ); + }); + + it('recovers as soon as one of the two directories is removed', async () => { + await seedSpace('canvas-dupe-fix', 'Fixable', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'a' }, + ]); + const copy = path.join(workspacePath, 'Fixable copy'); + cpSync(spaceDirectory('canvas-dupe-fix'), copy, { recursive: true }); + refreshCanvasDirIndex(); + await expect(getStructuredStore().spaces().list()).rejects.toThrow(); + + rmSync(copy, { recursive: true, force: true }); + refreshCanvasDirIndex(); + + const listed = await app.inject({ method: 'GET', url: '/canvas' }); + expect(listed.statusCode).toBe(200); + expect( + listed + .json() + .canvases.map((entry: { canvasId: string }) => entry.canvasId), + ).toContain('canvas-dupe-fix'); + }); +}); + +// ─── 5. GET /canvas/:id/references ────────────────────────────────────────── + +describe('GET /canvas/:canvasId/references', () => { + async function seedWorld(nodes: readonly SeedNode[]): Promise { + const worldId = await getStructuredStore().spaces().worldId(); + await writeTopology(worldId, nodes); + return worldId; + } + + it('resolves Portal and node references through the ports', async () => { + await seedSpace('canvas-src', 'Source', [ + { + id: 'node-target', + type: 'note', + label: 'Target', + content: 'target body', + }, + ]); + const worldId = await seedWorld([ + { + id: 'node-portal', + type: 'canvasRef', + label: null, + sidecar: false, + data: { targetCanvasId: 'canvas-src' }, + }, + { + id: 'node-ref', + type: 'nodeRef', + label: null, + sidecar: false, + data: { target: { canvasId: 'canvas-src', nodeId: 'node-target' } }, + }, + ]); + + const response = await app.inject({ + method: 'GET', + url: `/canvas/${worldId}/references`, + }); + + expect(response.statusCode).toBe(200); + const { references } = response.json(); + expect(references).toEqual([ + expect.objectContaining({ + kind: 'canvasRef', + targetCanvasId: 'canvas-src', + status: 'ok', + title: 'Source', + }), + expect.objectContaining({ + kind: 'nodeRef', + status: 'ok', + source: expect.objectContaining({ type: 'note', label: 'Target' }), + }), + ]); + }); + + it('reports a missing Space and a missing node distinctly', async () => { + await seedSpace('canvas-partial', 'Partial', [ + { id: 'node-present', type: 'note', label: 'Present', content: 'here' }, + ]); + const worldId = await seedWorld([ + { + id: 'node-portal', + type: 'canvasRef', + label: null, + sidecar: false, + data: { targetCanvasId: 'canvas-gone' }, + }, + { + id: 'node-ref', + type: 'nodeRef', + label: null, + sidecar: false, + data: { target: { canvasId: 'canvas-partial', nodeId: 'node-gone' } }, + }, + ]); + + const response = await app.inject({ + method: 'GET', + url: `/canvas/${worldId}/references`, + }); + + expect(response.statusCode).toBe(200); + expect( + response.json().references.map((ref: { status: string }) => ref.status), + ).toEqual(['canvas-missing', 'node-missing']); + }); + + it('refuses to resolve references for an ordinary Space', async () => { + await seedSpace('canvas-ordinary', 'Ordinary', []); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-ordinary/references', + }); + + expect(response.statusCode).toBe(400); + }); +}); + +// ─── 6. POST /canvas/:id/search ───────────────────────────────────────────── + +describe('POST /canvas/:canvasId/search', () => { + it('finds text that exists only in a node sidecar', async () => { + await seedSpace('canvas-search', 'Search', [ + { + id: 'node-a', + type: 'note', + label: 'Alpha', + content: 'the needle is in the body', + }, + { id: 'node-b', type: 'note', label: 'Beta', content: 'unrelated' }, + ]); + + const response = await app.inject({ + method: 'POST', + url: '/canvas/canvas-search/search', + payload: { query: 'needle' }, + }); + + expect(response.statusCode).toBe(200); + const events = ndjson(response.payload); + const matches = events.filter((event) => event.type === 'match'); + expect(matches).toHaveLength(1); + expect(matches[0]).toMatchObject({ + tier: 'content', + match: { + nodeId: 'node-a', + field: 'content', + snippet: expect.stringContaining('needle'), + }, + }); + expect(events.at(-1)).toMatchObject({ type: 'done' }); + }); + + it('answers 404 before opening a stream for an absent Space', async () => { + const response = await app.inject({ + method: 'POST', + url: '/canvas/canvas-absent/search', + payload: { query: 'needle' }, + }); + + expect(response.statusCode).toBe(404); + }); +}); + +// ─── 7. Bundle export and import ──────────────────────────────────────────── + +describe('Space bundle round trip', () => { + it('re-imports an exported bundle as a new Space with its node records', async () => { + await seedSpace('canvas-export', 'Exported', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'exported body' }, + ]); + + const exported = await app.inject({ + method: 'GET', + url: '/canvas/canvas-export/export', + }); + expect(exported.statusCode).toBe(200); + expect(exported.headers['content-disposition']).toContain('.huabu.zip'); + + const body = multipartBody('Exported.huabu.zip', exported.rawPayload); + const imported = await app.inject({ + method: 'POST', + url: '/canvas/import', + payload: body.payload, + headers: body.headers, + }); + + expect(imported.statusCode).toBe(200); + const newCanvasId = imported.json().canvasId; + expect(newCanvasId).not.toBe('canvas-export'); + + const record = await space(newCanvasId).read(); + expect(record?.canvasId).toBe(newCanvasId); + const nodes = await space(newCanvasId).nodes.list(); + expect(nodes.get('node-a')?.record.content).toContain('exported body'); + }); + + it('accepts a bundle that still carries the frozen canvas.json record name', async () => { + const bundle = await makeBundle({ + 'manifest.json': JSON.stringify({ version: '2', title: 'Legacy bundle' }), + 'canvas.json': JSON.stringify({ + canvasId: 'canvas-legacy-source', + title: 'Legacy bundle', + version: 3, + createdAt: 1, + updatedAt: 2, + state: { + nodes: [ + { + id: 'node-legacy', + type: 'note', + position: { x: 0, y: 0 }, + data: { label: 'Legacy' }, + }, + ], + edges: [], + }, + }), + 'nodes/Legacy.md': + '---\nid: node-legacy\ntype: note\nlabel: Legacy\n---\nlegacy body\n', + }); + + const body = multipartBody('legacy.huabu.zip', bundle); + const imported = await app.inject({ + method: 'POST', + url: '/canvas/import', + payload: body.payload, + headers: body.headers, + }); + + expect(imported.statusCode).toBe(200); + const canvasId = imported.json().canvasId; + const record = await space(canvasId).read(); + expect(record?.title).toBe('Legacy bundle'); + const nodes = await space(canvasId).nodes.list(); + expect(nodes.get('node-legacy')?.record.content).toContain('legacy body'); + }); + + it('de-duplicates the title when the imported name is already taken', async () => { + await seedSpace('canvas-taken', 'Shared name', []); + const bundle = await makeBundle({ + 'manifest.json': JSON.stringify({ version: '2', title: 'Shared name' }), + 'space.json': JSON.stringify({ + canvasId: 'canvas-other-source', + title: 'Shared name', + version: 0, + createdAt: 1, + updatedAt: 1, + state: { nodes: [], edges: [] }, + }), + }); + + const body = multipartBody('shared.huabu.zip', bundle); + const imported = await app.inject({ + method: 'POST', + url: '/canvas/import', + payload: body.payload, + headers: body.headers, + }); + + expect(imported.statusCode).toBe(200); + const record = await space(imported.json().canvasId).read(); + expect(record?.title).not.toBe('Shared name'); + expect(record?.title).toContain('Shared name'); + }); + + it('refuses a bundle with no Space record and leaves no staging directory', async () => { + const bundle = await makeBundle({ + 'manifest.json': JSON.stringify({ version: '2' }), + 'nodes/Orphan.md': '---\nid: node-orphan\n---\nno record\n', + }); + + const body = multipartBody('broken.huabu.zip', bundle); + const imported = await app.inject({ + method: 'POST', + url: '/canvas/import', + payload: body.payload, + headers: body.headers, + }); + + expect(imported.statusCode).toBe(400); + const listed = await app.inject({ method: 'GET', url: '/canvas' }); + expect(listed.json().canvases).toHaveLength(0); + }); + + it('resolves the record filename however the module graph was entered', async () => { + // `space-import.ts` reads the record filename from `layout.ts`, and the + // two sit in one import cycle: layout → workspace → the storage barrel → + // the composition root → space-import. Whichever module the process + // happens to load first decides whether `layout.ts` has finished + // initializing by the time this file evaluates, so a module-scope capture + // of that constant is `undefined` for the life of the process under some + // orders and correct under others — and every bundle import then answers + // 500 with no clue why. + // + // A static import cannot express that: the order this file's own imports + // produce is the safe one. Reset the registry and re-enter the cycle + // through a Disk module, which is the order that breaks it. + vi.resetModules(); + await import('../storage/backends/disk/canvas-dirs.js'); + const reloadedStorage = await import('../storage/index.js'); + const reloadedWorkspace = await import('../workspace.js'); + + const staging = mkdtempSync(path.join(tmpdir(), 'huabu-portable-reads-4-')); + try { + reloadedWorkspace.setWorkspacePath(staging); + const staged = reloadedStorage.stageSpaceImport('canvas-staged'); + expect(staged).not.toBeNull(); + if (!staged) return; + mkdirSync(staged.stagingDirectory, { recursive: true }); + writeFileSync( + path.join(staged.stagingDirectory, 'space.json'), + JSON.stringify({ + canvasId: 'canvas-staged-source', + title: 'Staged', + version: 0, + createdAt: 1, + updatedAt: 1, + state: { nodes: [], edges: [] }, + }), + 'utf8', + ); + + await expect(staged.readRecord()).resolves.toMatchObject({ + canvasId: 'canvas-staged-source', + }); + } finally { + rmSync(staging, { recursive: true, force: true }); + // The reloaded copy of `workspace.ts` holds its own module state; put + // the one this file uses back where the shared setup left it. + setWorkspacePath(workspacePath); + resetStorageCache(); + } + }); + + it('answers 404 when exporting a Space that does not exist', async () => { + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-absent/export', + }); + + expect(response.statusCode).toBe(404); + }); +}); + +// ─── 8. Per-node content routes ───────────────────────────────────────────── + +describe('per-node content routes', () => { + it('serves one node record and round-trips an edit back through it', async () => { + await seedSpace('canvas-content', 'Content', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'first body' }, + ]); + + const before = await app.inject({ + method: 'GET', + url: '/canvas/canvas-content/nodes/node-a/content', + }); + expect(before.statusCode).toBe(200); + expect(before.json()).toMatchObject({ + nodeId: 'node-a', + type: 'note', + label: 'Alpha', + content: 'first body', + }); + + const written = await app.inject({ + method: 'PUT', + url: '/canvas/canvas-content/nodes/node-a/content', + payload: { nodeType: 'note', content: 'second body' }, + }); + expect(written.statusCode).toBe(200); + + const after = await app.inject({ + method: 'GET', + url: '/canvas/canvas-content/nodes/node-a/content', + }); + expect(after.json().content).toBe('second body'); + // Sidecar writes are outside the Space record's version counter. + expect((await space('canvas-content').read())?.version).toBe(1); + }); + + it('returns a placeholder rather than 404 for a node with no sidecar', async () => { + await seedSpace('canvas-content-bare', 'Bare content', [ + { id: 'node-bare', type: 'note', label: 'Bare', sidecar: false }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-content-bare/nodes/node-bare/content', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + nodeId: 'node-bare', + contentMissing: true, + content: '', + }); + }); + + it('keeps a node whose frontmatter a user broke editable and deletable', async () => { + // The reason the port's single-node read is strict about reachability but + // lenient about content: refusing the read would make a hand-broken node + // unreachable through exactly the two routes that could repair it. + await seedSpace('canvas-content-broken', 'Broken content', [ + { id: 'node-broken', type: 'note', label: 'Broken', sidecar: false }, + ]); + writeSidecarByHand( + 'canvas-content-broken', + 'node-broken.md', + '---\nkeywords: [oops\n---\nsalvageable\n', + ); + + const read = await app.inject({ + method: 'GET', + url: '/canvas/canvas-content-broken/nodes/node-broken/content', + }); + expect(read.statusCode).toBe(200); + expect(read.json().content).toContain('salvageable'); + + const repaired = await app.inject({ + method: 'PUT', + url: '/canvas/canvas-content-broken/nodes/node-broken/content', + payload: { nodeType: 'note', content: 'repaired', label: 'Broken' }, + }); + expect(repaired.statusCode).toBe(200); + expect( + (await space('canvas-content-broken').nodes.read('node-broken'))?.record + .content, + ).toContain('repaired'); + + const deleted = await app.inject({ + method: 'DELETE', + url: '/canvas/canvas-content-broken/nodes/node-broken', + }); + expect(deleted.statusCode).toBe(200); + expect( + await space('canvas-content-broken').nodes.read('node-broken'), + ).toBeNull(); + }); + + it('renders a node two sidecars claim, and refuses to overwrite either', async () => { + // Only a filesystem can produce this, so only Disk answers it: the read + // path keeps the node visible with a duplicate hint so a user can fix it, + // while the write path hard-fails rather than picking a file. + await seedSpace('canvas-dupe-node', 'Duplicate node', [ + { id: 'node-a', type: 'note', label: 'Alpha', content: 'original' }, + ]); + writeSidecarByHand( + 'canvas-dupe-node', + 'Alpha copy.md', + '---\nid: node-a\ntype: note\nlabel: Alpha\n---\nsecond claim\n', + ); + + const response = await app.inject({ + method: 'GET', + url: '/canvas/canvas-dupe-node/nodes/node-a/content', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + nodeId: 'node-a', + contentDuplicate: true, + }); + expect(response.json().duplicateFiles).toHaveLength(2); + + const put = await space('canvas-dupe-node').nodes.put({ + nodeId: 'node-a', + record: { + nodeId: 'node-a', + type: 'note', + label: 'Alpha', + content: 'overwrite attempt', + }, + }); + expect(put).toMatchObject({ ok: false, reason: 'duplicate-node' }); + }); +}); + +// ─── 9. Executor batches ──────────────────────────────────────────────────── + +describe('POST /canvas/:canvasId/execute', () => { + it('hydrates prestate through the ports and lands nodes plus sidecars', async () => { + await seedSpace('canvas-exec', 'Exec', [ + { id: 'node-existing', type: 'note', label: 'Existing', content: 'kept' }, + ]); + + const response = await app.inject({ + method: 'POST', + url: '/canvas/canvas-exec/execute', + payload: { + originator: { source: 'ui' }, + commands: [ + { + type: 'CREATE_NODES', + nodes: [ + { + id: 'node-new', + nodeType: 'note', + position: { x: 40, y: 40 }, + data: { label: 'New', content: 'created by the executor' }, + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + canvasId: 'canvas-exec', + fromVersion: 1, + toVersion: 2, + }); + + const nodes = await space('canvas-exec').nodes.list(); + expect([...nodes.keys()].sort()).toEqual(['node-existing', 'node-new']); + expect(nodes.get('node-new')?.record.content).toContain( + 'created by the executor', + ); + // The prestate the batch read from is still there afterwards. + expect(nodes.get('node-existing')?.record.content).toBe('kept'); + }); + + it('answers 404 for a batch addressed to a Space that does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/canvas/canvas-absent/execute', + payload: { originator: { source: 'ui' }, commands: [] }, + }); + + expect(response.statusCode).toBe(404); + }); +}); + +// ─── 10. Workspace switching ──────────────────────────────────────────────── + +describe('a handle bound to one Workspace', () => { + it('refuses to read a newly activated Workspace instead of resolving into it', async () => { + await seedSpace('canvas-bound', 'Bound', [ + { + id: 'node-a', + type: 'note', + label: 'Alpha', + content: 'first workspace', + }, + ]); + const retained = space('canvas-bound'); + const retainedNodes = retained.nodes; + const retainedTree = retained.diskTree; + expect(retainedTree).not.toBeNull(); + + const second = mkdtempSync(path.join(tmpdir(), 'huabu-portable-reads-2-')); + try { + setWorkspacePath(second); + resetStorageCache(); + + await expect(retainedNodes.list()).rejects.toThrow(/inactive workspace/i); + await expect(retainedNodes.read('node-a')).rejects.toThrow( + /inactive workspace/i, + ); + expect(() => retainedTree?.directory()).toThrow(/inactive workspace/i); + } finally { + rmSync(second, { recursive: true, force: true }); + setWorkspacePath(workspacePath); + resetStorageCache(); + } + }); + + it('serves the new Workspace through a freshly resolved handle', async () => { + await seedSpace('canvas-bound-2', 'Bound two', []); + + const second = mkdtempSync(path.join(tmpdir(), 'huabu-portable-reads-3-')); + try { + setWorkspacePath(second); + resetStorageCache(); + await getStructuredStore().spaces().ensureWorld(); + + expect(await space('canvas-bound-2').read()).toBeNull(); + expect(await getStructuredStore().spaces().list()).toEqual([]); + } finally { + rmSync(second, { recursive: true, force: true }); + setWorkspacePath(workspacePath); + resetStorageCache(); + } + }); +}); diff --git a/apps/server/src/modules/canvas/snapshot-nodes.ts b/apps/server/src/modules/canvas/snapshot-nodes.ts index 356f5c048..faf621b69 100644 --- a/apps/server/src/modules/canvas/snapshot-nodes.ts +++ b/apps/server/src/modules/canvas/snapshot-nodes.ts @@ -68,8 +68,9 @@ import { import { getSketchRenderedSize } from '@huabu/shared/canvas-engine'; import { RASTERIZABLE_IMAGE_EXT_MIME } from '../../utils/mime.js'; -import { space, getCanvasStore } from '../storage/index.js'; +import { space } from '../storage/index.js'; +import type { Space } from '../storage/index.js'; import type { SketchNodeData, SnapshotNodesQueryParams, @@ -404,14 +405,14 @@ const IMAGE_EXT_MIME = RASTERIZABLE_IMAGE_EXT_MIME; * Returns `null` when the node has no sidecar or the key is * missing/blank. */ -function readSidecarString( - store: ReturnType, +async function readSidecarString( + handle: Space, nodeId: string, key: 'src', -): string | null { - const sidecar = store.readNode(nodeId); - if (!sidecar) return null; - const value = sidecar[key]; +): Promise { + const record = (await handle.nodes.read(nodeId))?.record; + if (!record) return null; + const value = record[key]; return typeof value === 'string' && value.length > 0 ? value : null; } @@ -440,17 +441,17 @@ export interface ContextImage { * this image. */ async function loadContextImage( - store: ReturnType, + handle: Space, node: CanvasNode, ): Promise { - const src = readSidecarString(store, node.id, 'src'); + const src = await readSidecarString(handle, node.id, 'src'); if (!src) return null; const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; if (!mimeType) return null; const { width, height } = nodeBoxSize(node); if (width <= 0 || height <= 0) return null; - const bytes = await space(store.canvasId).blobs.read(src); + const bytes = await handle.blobs.read(src); if (!bytes) return null; return { node, resolvedSrc: src, bytes, mimeType, width, height }; } @@ -795,11 +796,11 @@ async function resampleImageBytes( * so repeated calls with the same parameters are O(1) cache hits. */ async function maybeResizeImageArtifact( - store: ReturnType, + handle: Space, src: string, maxEdge: number, ): Promise<{ src: string; width: number; height: number } | null> { - const blobs = space(store.canvasId).blobs; + const blobs = handle.blobs; const ext = path.extname(src).toLowerCase(); const mimeType = IMAGE_EXT_MIME[ext]; if (!mimeType) return null; @@ -860,8 +861,8 @@ export async function snapshotNodesToArtifacts( Math.min(SPACE_SNAPSHOT_MAX_PIXELS, args.maxPixels ?? CLUSTER_MAX_PIXELS), ); - const store = getCanvasStore(args.canvasId); - const canvas = store.read(); + const handle = space(args.canvasId); + const canvas = await handle.read(); if (!canvas) { throw new SnapshotNodeError( `Canvas ${args.canvasId} not found`, @@ -974,7 +975,7 @@ export async function snapshotNodesToArtifacts( // resvg downscale, instead of building a full composite SVG. if (cluster.length === 1 && cluster[0].type === 'image') { const entry = cluster[0]; - const src = readSidecarString(store, entry.node.id, 'src'); + const src = await readSidecarString(handle, entry.node.id, 'src'); if (!src) { if (entry.fromFrame) continue; throw new SnapshotNodeError( @@ -982,7 +983,7 @@ export async function snapshotNodesToArtifacts( 'invalid_snapshot_request', ); } - const resized = await maybeResizeImageArtifact(store, src, maxEdge); + const resized = await maybeResizeImageArtifact(handle, src, maxEdge); if (resized) { results.push({ src: resized.src, @@ -1012,7 +1013,7 @@ export async function snapshotNodesToArtifacts( const contextImages: ContextImage[] = []; for (const entry of imageEntries) { - const loaded = await loadContextImage(store, entry.node); + const loaded = await loadContextImage(handle, entry.node); if (loaded) { contextImages.push(loaded); continue; @@ -1025,7 +1026,7 @@ export async function snapshotNodesToArtifacts( // (the strokes will still render; losing one backdrop is // preferable to failing the whole batch). if (entry.fromFrame) continue; - const src = readSidecarString(store, entry.node.id, 'src'); + const src = await readSidecarString(handle, entry.node.id, 'src'); if (!src) { throw new SnapshotNodeError( `Node ${entry.node.id} (image) has no src — nothing to return. The artifact may have been deleted, or the node's markdown sidecar (nodes/