pmx-canvas 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +60 -0
- package/Readme.md +44 -16
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +844 -710
- package/dist/canvas/index.js +92 -90
- package/dist/canvas/mcp-app.html +7878 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +55 -55
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -1
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +3 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +208 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +214 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +80 -15
- package/docs/mcp.md +198 -3
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +31 -16
- package/skills/pmx-canvas/evals/evals.json +69 -0
- package/skills/pmx-canvas/references/full-reference.md +14 -10
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +54 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +8 -3
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +208 -220
- package/src/client/canvas/CanvasViewport.tsx +6 -2
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +25 -23
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/FeedbackDialog.tsx +10 -8
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +58 -108
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +22 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +7 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +24 -6
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +844 -710
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +79 -15
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +6 -24
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +86 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +19 -3
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +1 -1
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +38 -4
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +86 -6
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/ax-surface-protocol.ts +3 -0
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- package/src/client/canvas/kind-colors.ts +0 -23
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { listOperations, OperationError } from '../server/operations/index.js';
|
|
4
|
+
import { matchOperationPath, readOperationInput } from '../server/operations/http.js';
|
|
5
|
+
import type { CanvasAccess } from './canvas-access.js';
|
|
6
|
+
|
|
7
|
+
// Explicitly bounded to workbench operations. No shell, automation, arbitrary
|
|
8
|
+
// HTTP proxy, external MCP tool calls, or caller-supplied authority headers.
|
|
9
|
+
const writes = new Set([
|
|
10
|
+
'board.create',
|
|
11
|
+
'board.open',
|
|
12
|
+
'board.update',
|
|
13
|
+
'board.create-from',
|
|
14
|
+
'board.delete',
|
|
15
|
+
'node.add',
|
|
16
|
+
'jsonrender.add',
|
|
17
|
+
'graph.add',
|
|
18
|
+
'node.update',
|
|
19
|
+
'node.remove',
|
|
20
|
+
'node.refresh',
|
|
21
|
+
'edge.add',
|
|
22
|
+
'edge.update',
|
|
23
|
+
'edge.remove',
|
|
24
|
+
'group.create',
|
|
25
|
+
'group.add',
|
|
26
|
+
'group.remove',
|
|
27
|
+
'pin.set',
|
|
28
|
+
'annotation.add',
|
|
29
|
+
'annotation.remove',
|
|
30
|
+
'theme.set',
|
|
31
|
+
'viewport.set',
|
|
32
|
+
'canvas.apply-updates',
|
|
33
|
+
'canvas.clear',
|
|
34
|
+
'arrange',
|
|
35
|
+
'node.focus',
|
|
36
|
+
'view.fit',
|
|
37
|
+
'snapshot.save',
|
|
38
|
+
'snapshot.restore',
|
|
39
|
+
'snapshot.delete',
|
|
40
|
+
'tour.set',
|
|
41
|
+
'ax.focus.set',
|
|
42
|
+
'ax.policy.set',
|
|
43
|
+
'ax.work.create',
|
|
44
|
+
'ax.work.update',
|
|
45
|
+
'ax.review.add',
|
|
46
|
+
'ax.review.update',
|
|
47
|
+
'ax.elicitation.respond',
|
|
48
|
+
'ax.mode.resolve',
|
|
49
|
+
'ax.steer',
|
|
50
|
+
'ax.event.record',
|
|
51
|
+
'ax.presence.set',
|
|
52
|
+
'ax.interaction.submit',
|
|
53
|
+
'intent.clear',
|
|
54
|
+
'import.request',
|
|
55
|
+
'import.cancel',
|
|
56
|
+
'import.commit',
|
|
57
|
+
'export.run',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function readableAsset(path: string): boolean {
|
|
61
|
+
return (
|
|
62
|
+
[
|
|
63
|
+
'/health',
|
|
64
|
+
'/api/workbench/poll',
|
|
65
|
+
'/api/workbench/state',
|
|
66
|
+
'/api/file',
|
|
67
|
+
'/api/canvas/json-render/view',
|
|
68
|
+
'/api/canvas/file-bytes',
|
|
69
|
+
].includes(path) ||
|
|
70
|
+
/^\/api\/canvas\/(surface|image|frame-documents|exports)\/[^/]+$/.test(path) ||
|
|
71
|
+
/^\/api\/canvas\/attachments\/[^/]+\/bytes$/.test(path)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** HTTP-shaped replies let the real workbench retain its existing API contracts. */
|
|
76
|
+
export function registerWorkbenchApp(server: McpServer, ensureCanvas: () => Promise<CanvasAccess>): void {
|
|
77
|
+
server.registerTool(
|
|
78
|
+
'pmx_workbench_request',
|
|
79
|
+
{
|
|
80
|
+
title: 'PMX workbench transport',
|
|
81
|
+
description: 'App-only transport for the PMX workbench. Not an assistant tool.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
path: z.string().max(8192),
|
|
84
|
+
method: z.enum(['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'HEAD']),
|
|
85
|
+
body: z.string().max(4_000_000).optional(),
|
|
86
|
+
expectedBoardId: z.string().nullable(),
|
|
87
|
+
},
|
|
88
|
+
// Workbench webpage/image actions can fetch user-chosen external URLs.
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
90
|
+
_meta: { ui: { visibility: ['app'] } },
|
|
91
|
+
},
|
|
92
|
+
async (input) => {
|
|
93
|
+
const reply = (status: number, body: string, contentType = 'application/json', encoding = 'text') => ({
|
|
94
|
+
content: [
|
|
95
|
+
{ type: 'text' as const, text: status < 400 ? 'Workbench request completed.' : 'Workbench request refused.' },
|
|
96
|
+
],
|
|
97
|
+
_meta: { response: { status, body, contentType, encoding } },
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
const canvas = await ensureCanvas();
|
|
101
|
+
const base = canvas.remoteBaseUrl ?? `http://127.0.0.1:${canvas.port}`;
|
|
102
|
+
const url = new URL(input.path, base);
|
|
103
|
+
if (
|
|
104
|
+
!input.path.startsWith('/') ||
|
|
105
|
+
input.path.startsWith('//') ||
|
|
106
|
+
input.path.includes('\\') ||
|
|
107
|
+
url.origin !== new URL(base).origin ||
|
|
108
|
+
url.username ||
|
|
109
|
+
url.password ||
|
|
110
|
+
url.hash
|
|
111
|
+
) {
|
|
112
|
+
throw new OperationError('Only local PMX workbench paths are allowed.', 403);
|
|
113
|
+
}
|
|
114
|
+
const req = new Request(url, {
|
|
115
|
+
method: input.method,
|
|
116
|
+
headers: { 'Content-Type': 'application/json' },
|
|
117
|
+
...(input.body === undefined ? {} : { body: input.body }),
|
|
118
|
+
});
|
|
119
|
+
for (const op of listOperations()) {
|
|
120
|
+
const route = op.http;
|
|
121
|
+
if (!route || route.method !== input.method) continue;
|
|
122
|
+
const params = matchOperationPath(route.path, url.pathname);
|
|
123
|
+
if (!params) continue;
|
|
124
|
+
if (!url.pathname.startsWith('/api/canvas/') || (input.method !== 'GET' && !writes.has(op.name)))
|
|
125
|
+
throw new OperationError('This action requires the local PMX workbench.', 403);
|
|
126
|
+
const args = route.readInput
|
|
127
|
+
? await route.readInput(req, params, url)
|
|
128
|
+
: await readOperationInput(req, params, url);
|
|
129
|
+
if (input.method !== 'GET') args.expectedBoardId = input.expectedBoardId;
|
|
130
|
+
// This profile is one agent transport, never browser/system authority
|
|
131
|
+
// or a caller-selected writer whose wider territory could bypass a fence.
|
|
132
|
+
args.source = 'mcp';
|
|
133
|
+
delete args.agentId;
|
|
134
|
+
if (
|
|
135
|
+
op.name === 'ax.interaction.submit' &&
|
|
136
|
+
(args.type === 'ax.approval.resolve' || args.type === 'ax.flow.materialize')
|
|
137
|
+
)
|
|
138
|
+
throw new OperationError('Resolve approvals and materialize flows in the local PMX workbench.', 403);
|
|
139
|
+
if (op.name === 'node.add' || op.name === 'node.update') {
|
|
140
|
+
const data = args.data;
|
|
141
|
+
const nodeType = op.name === 'node.add' ? args.type : (await canvas.getNode(String(args.id)))?.type;
|
|
142
|
+
// Trace tool names describe past activity; they never launch a backend.
|
|
143
|
+
const launchFields = ['transportConfig', 'toolInput', 'serverName', 'appSessionId'];
|
|
144
|
+
if (nodeType !== 'trace') launchFields.push('toolName');
|
|
145
|
+
if (
|
|
146
|
+
(op.name === 'node.add' && args.type === 'mcp-app') ||
|
|
147
|
+
(data && typeof data === 'object' && launchFields.some((field) => field in data))
|
|
148
|
+
)
|
|
149
|
+
throw new OperationError('Configure external MCP apps in the local PMX workbench.', 403);
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
op.name === 'node.update' &&
|
|
153
|
+
('content' in args || (args.data && typeof args.data === 'object' && 'content' in args.data))
|
|
154
|
+
) {
|
|
155
|
+
if (typeof args.expectedContentRevision !== 'number')
|
|
156
|
+
throw new OperationError('Read the note before editing: a content revision is required.', 409);
|
|
157
|
+
const node = await canvas.getNode(String(args.id));
|
|
158
|
+
if (node?.data.path) throw new OperationError('Edit file-linked content in the local PMX workbench.', 403);
|
|
159
|
+
}
|
|
160
|
+
const result = await canvas.invoker().invoke(op.name, args);
|
|
161
|
+
return reply(route.status ? route.status(result) : 200, JSON.stringify(result));
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
!((input.method === 'GET' || input.method === 'HEAD') && readableAsset(url.pathname)) &&
|
|
165
|
+
!(input.method === 'POST' && url.pathname === '/api/render')
|
|
166
|
+
) {
|
|
167
|
+
throw new OperationError('This action requires the local PMX workbench.', 403);
|
|
168
|
+
}
|
|
169
|
+
// Resolve only PMX's internal surface redirects. Never follow to arbitrary
|
|
170
|
+
// hosts or return the workbench HTML (which carries its human boot token).
|
|
171
|
+
let target = url;
|
|
172
|
+
let response: Response | undefined;
|
|
173
|
+
for (let redirects = 0; redirects < 3; redirects++) {
|
|
174
|
+
response = await fetch(target, {
|
|
175
|
+
method: input.method,
|
|
176
|
+
body: input.body,
|
|
177
|
+
headers: { 'Content-Type': 'application/json' },
|
|
178
|
+
redirect: 'manual',
|
|
179
|
+
signal: AbortSignal.timeout(15_000),
|
|
180
|
+
});
|
|
181
|
+
if (response.status < 300 || response.status >= 400) break;
|
|
182
|
+
const next = new URL(response.headers.get('location') ?? '', target);
|
|
183
|
+
if (next.origin !== url.origin || !readableAsset(next.pathname))
|
|
184
|
+
throw new OperationError('External surfaces must be opened outside the embedded workbench.', 403);
|
|
185
|
+
target = next;
|
|
186
|
+
}
|
|
187
|
+
if (!response || (response.status >= 300 && response.status < 400))
|
|
188
|
+
throw new OperationError('Could not resolve the PMX surface.');
|
|
189
|
+
const bytes = await response.arrayBuffer();
|
|
190
|
+
if (bytes.byteLength > 24 * 1024 * 1024)
|
|
191
|
+
throw new OperationError('This asset is too large for the MCP connection.');
|
|
192
|
+
const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
|
|
193
|
+
const text = /^(text\/|application\/(json|javascript))/.test(contentType);
|
|
194
|
+
return reply(
|
|
195
|
+
response.status,
|
|
196
|
+
text ? new TextDecoder().decode(bytes) : Buffer.from(bytes).toString('base64'),
|
|
197
|
+
contentType,
|
|
198
|
+
text ? 'text' : 'base64',
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof OperationError)
|
|
202
|
+
return reply(error.status, JSON.stringify({ ok: false, error: error.message }));
|
|
203
|
+
console.error('[workbench-app] request failed', error);
|
|
204
|
+
return reply(
|
|
205
|
+
502,
|
|
206
|
+
JSON.stringify({ ok: false, error: 'PMX workbench request failed. Check the daemon and retry.' }),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
PRESENCE_TOOLING_SETTLE_MS,
|
|
33
33
|
TRANSPORT_SOURCES,
|
|
34
34
|
} from '../shared/agent-presence.js';
|
|
35
|
-
import { serializeNodeForAgentContext } from '
|
|
35
|
+
import { serializeNodeForAgentContext } from '../shared/agent-context.js';
|
|
36
36
|
import type { PmxAxActivityKind } from './ax-state.js';
|
|
37
37
|
import { type CanvasNodeState, canvasState } from './canvas-state.js';
|
|
38
38
|
import { OperationError } from './operations/types.js';
|
|
@@ -205,6 +205,8 @@ interface StoredPresence extends AgentPresence {
|
|
|
205
205
|
lastActivityNodeId: string | null;
|
|
206
206
|
/** Snapshot of the board taken when this session attached (the receipt diffs against it). */
|
|
207
207
|
startSnapshotId: string | null;
|
|
208
|
+
/** When this session attached (ISO); the receipt counts reads and pins from here. */
|
|
209
|
+
startedAt: string | null;
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
function presenceKey(source: string, agentId: string | null | undefined): string {
|
|
@@ -230,7 +232,12 @@ export function estimateContextBudget(): ContextBudget {
|
|
|
230
232
|
/** Returns the id of the pre-session snapshot the server took, if any. */
|
|
231
233
|
type SessionStartListener = (presence: AgentPresence) => string | null;
|
|
232
234
|
export type SessionEndReason = 'human' | 'agent' | 'idle-timeout';
|
|
233
|
-
type SessionEndListener = (
|
|
235
|
+
type SessionEndListener = (
|
|
236
|
+
presence: AgentPresence,
|
|
237
|
+
startSnapshotId: string | null,
|
|
238
|
+
endedBy: SessionEndReason,
|
|
239
|
+
startedAt: string | null,
|
|
240
|
+
) => void;
|
|
234
241
|
|
|
235
242
|
export class AgentPresenceRegistry {
|
|
236
243
|
private readonly presences = new Map<string, StoredPresence>();
|
|
@@ -392,6 +399,7 @@ export class AgentPresenceRegistry {
|
|
|
392
399
|
phaseSetMs: now,
|
|
393
400
|
toolingUntilMs: null,
|
|
394
401
|
startSnapshotId: null,
|
|
402
|
+
startedAt: null,
|
|
395
403
|
};
|
|
396
404
|
const wasAttached = stored.attached;
|
|
397
405
|
stored.lastSeenMs = now;
|
|
@@ -448,6 +456,7 @@ export class AgentPresenceRegistry {
|
|
|
448
456
|
// moment stays the receipt baseline via the inherited snapshot. A
|
|
449
457
|
// placeholder that already adopted work is a real session and stays.
|
|
450
458
|
let inheritedSnapshotId: string | null = null;
|
|
459
|
+
let inheritedStartedAt: string | null = null;
|
|
451
460
|
if (stored.source !== 'browser') {
|
|
452
461
|
const placeholder = [...this.presences.values()].find(
|
|
453
462
|
(presence) =>
|
|
@@ -463,13 +472,20 @@ export class AgentPresenceRegistry {
|
|
|
463
472
|
if (target === placeholder.sessionId) this.aliases.set(channel, key);
|
|
464
473
|
}
|
|
465
474
|
inheritedSnapshotId = placeholder.startSnapshotId;
|
|
475
|
+
inheritedStartedAt = placeholder.startedAt;
|
|
466
476
|
}
|
|
467
477
|
}
|
|
468
478
|
stored.startSnapshotId = inheritedSnapshotId ?? this.onSessionStart(this.publicView(stored, now));
|
|
479
|
+
stored.startedAt = inheritedStartedAt ?? new Date(now).toISOString();
|
|
469
480
|
}
|
|
470
481
|
if (input.attached === false) {
|
|
471
482
|
if (wasAttached)
|
|
472
|
-
this.onSessionEnd(
|
|
483
|
+
this.onSessionEnd(
|
|
484
|
+
this.publicView(stored, now),
|
|
485
|
+
stored.startSnapshotId,
|
|
486
|
+
input.endedBy ?? 'agent',
|
|
487
|
+
stored.startedAt,
|
|
488
|
+
);
|
|
473
489
|
// An explicit detach always removes the presence. In particular, a
|
|
474
490
|
// worker that reports attached:false must not be refreshed into another
|
|
475
491
|
// full activity TTL as an external writer.
|
|
@@ -481,7 +497,24 @@ export class AgentPresenceRegistry {
|
|
|
481
497
|
}
|
|
482
498
|
if (input.focusNodeId !== undefined) stored.focusNodeId = input.focusNodeId;
|
|
483
499
|
if (input.parentAgentId !== undefined) stored.parentAgentId = input.parentAgentId;
|
|
484
|
-
if (input.cursor !== undefined)
|
|
500
|
+
if (input.cursor !== undefined) {
|
|
501
|
+
// A MOVING cursor is the agent visibly working: it keeps a live explicit
|
|
502
|
+
// `tooling` from settling (a host streaming cursor moves after one
|
|
503
|
+
// `tooling` report read as Idle within seconds). A stationary heartbeat
|
|
504
|
+
// is not activity, and `thinking` keeps its own longer clock (below).
|
|
505
|
+
const moved =
|
|
506
|
+
input.cursor !== null && (stored.cursor?.x !== input.cursor.x || stored.cursor?.y !== input.cursor.y);
|
|
507
|
+
if (
|
|
508
|
+
moved &&
|
|
509
|
+
input.phase === undefined &&
|
|
510
|
+
stored.phase === 'tooling' &&
|
|
511
|
+
stored.toolingUntilMs !== null &&
|
|
512
|
+
stored.toolingUntilMs > now
|
|
513
|
+
) {
|
|
514
|
+
stored.toolingUntilMs = now + PRESENCE_TOOLING_SETTLE_MS;
|
|
515
|
+
}
|
|
516
|
+
stored.cursor = input.cursor;
|
|
517
|
+
}
|
|
485
518
|
if (input.contextUsage !== undefined) stored.contextUsage = input.contextUsage;
|
|
486
519
|
if (input.phase === 'tooling' && input.derivedTooling) {
|
|
487
520
|
// Copilot's finding: an op-derived tooling touch used to CLOBBER the
|
|
@@ -585,7 +618,8 @@ export class AgentPresenceRegistry {
|
|
|
585
618
|
const removed = this.presences.delete(sessionId);
|
|
586
619
|
if (removed) {
|
|
587
620
|
this.dropAliasesTo(sessionId);
|
|
588
|
-
if (stored?.attached)
|
|
621
|
+
if (stored?.attached)
|
|
622
|
+
this.onSessionEnd(this.publicView(stored, Date.now()), stored.startSnapshotId, 'agent', stored.startedAt);
|
|
589
623
|
this.scheduleEmit();
|
|
590
624
|
}
|
|
591
625
|
this.maybeStopSweeper();
|
|
@@ -700,7 +734,12 @@ export class AgentPresenceRegistry {
|
|
|
700
734
|
this.presences.delete(key);
|
|
701
735
|
this.dropAliasesTo(key);
|
|
702
736
|
if (presence.attached)
|
|
703
|
-
this.onSessionEnd(
|
|
737
|
+
this.onSessionEnd(
|
|
738
|
+
this.publicView(presence, now),
|
|
739
|
+
presence.startSnapshotId,
|
|
740
|
+
'idle-timeout',
|
|
741
|
+
presence.startedAt,
|
|
742
|
+
);
|
|
704
743
|
changed = true;
|
|
705
744
|
continue;
|
|
706
745
|
}
|
package/src/server/ax-context.ts
CHANGED
package/src/server/ax-state.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
2
2
|
import { clampGateTtlMs, DEFAULT_GATE_TTL_MS } from '../shared/approval-gates.js';
|
|
3
3
|
import type { AxApprovalStatus, AxEventKind, AxWorkItemStatus } from '../shared/ax-kinds.js';
|
|
4
|
-
import type { AgentContextNode } from '
|
|
4
|
+
import type { AgentContextNode } from '../shared/agent-context.js';
|
|
5
5
|
import type { ActorAttribution } from './attribution.js';
|
|
6
6
|
|
|
7
7
|
export type PmxAxSource = 'agent' | 'amp' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
|
|
@@ -208,6 +208,8 @@ async function collect(
|
|
|
208
208
|
if (!read) return null;
|
|
209
209
|
const { board, state } = read;
|
|
210
210
|
const theme = canvasThemeScheme(state.theme ?? canvasState.theme);
|
|
211
|
+
// Exports collapse to a scheme; surface documents take the theme that draws it.
|
|
212
|
+
const surfaceTheme = theme === 'light' ? 'daylight' : 'harbor';
|
|
211
213
|
const remoteImages = new Set<string>();
|
|
212
214
|
const links = new Set<string>();
|
|
213
215
|
const frameNetworkDestinations = new Set<string>();
|
|
@@ -383,7 +385,8 @@ async function collect(
|
|
|
383
385
|
const html = text(data.html) || text(data.content);
|
|
384
386
|
collectFrameNetworkDestinations(html, frameNetworkDestinations);
|
|
385
387
|
manifest.frames += 1;
|
|
386
|
-
if (withFrames)
|
|
388
|
+
if (withFrames)
|
|
389
|
+
card.frame = buildHtmlSurfaceDocument(html, { theme: surfaceTheme, title, inlineThemeCss: themeCss });
|
|
387
390
|
break;
|
|
388
391
|
}
|
|
389
392
|
case 'mermaid':
|
|
@@ -395,7 +398,7 @@ async function collect(
|
|
|
395
398
|
'<script src="/canvas/mermaid-entry.js"></script>',
|
|
396
399
|
`<script src="${MERMAID_SRC}"></script>`,
|
|
397
400
|
),
|
|
398
|
-
{ theme, title, inlineThemeCss: themeCss },
|
|
401
|
+
{ theme: surfaceTheme, title, inlineThemeCss: themeCss },
|
|
399
402
|
);
|
|
400
403
|
}
|
|
401
404
|
break;
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Database } from 'bun:sqlite';
|
|
9
|
+
import type { ActorAttribution } from './attribution.js';
|
|
9
10
|
import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
|
|
10
11
|
import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
|
|
11
12
|
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
@@ -48,7 +49,7 @@ const SCHEMA_VERSION = 2;
|
|
|
48
49
|
|
|
49
50
|
export type CanvasTheme = CanvasThemeName;
|
|
50
51
|
|
|
51
|
-
export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = '
|
|
52
|
+
export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'harbor'): CanvasTheme {
|
|
52
53
|
return normalizeCanvasThemeName(value, fallback);
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -122,6 +123,7 @@ const SCHEMA_SQL = `
|
|
|
122
123
|
CREATE TABLE IF NOT EXISTS context_pins (
|
|
123
124
|
board_id TEXT NOT NULL,
|
|
124
125
|
node_id TEXT NOT NULL,
|
|
126
|
+
meta TEXT,
|
|
125
127
|
PRIMARY KEY (board_id, node_id)
|
|
126
128
|
);
|
|
127
129
|
|
|
@@ -185,6 +187,7 @@ const SCHEMA_SQL = `
|
|
|
185
187
|
CREATE TABLE IF NOT EXISTS snapshot_pins (
|
|
186
188
|
snapshot_id TEXT NOT NULL,
|
|
187
189
|
node_id TEXT NOT NULL,
|
|
190
|
+
meta TEXT,
|
|
188
191
|
PRIMARY KEY (snapshot_id, node_id)
|
|
189
192
|
);
|
|
190
193
|
|
|
@@ -335,10 +338,29 @@ export interface PersistedCanvasState {
|
|
|
335
338
|
edges: CanvasEdge[];
|
|
336
339
|
annotations?: CanvasAnnotation[];
|
|
337
340
|
contextPins: string[];
|
|
341
|
+
/** Who pinned each context pin, when and why; pins without an entry have an unknown pinner. */
|
|
342
|
+
contextPinMeta?: Record<string, ContextPinMeta>;
|
|
338
343
|
ax?: PmxAxState;
|
|
339
344
|
revisionState?: PersistedRevisionState;
|
|
340
345
|
}
|
|
341
346
|
|
|
347
|
+
export interface ContextPinMeta {
|
|
348
|
+
pinnedBy: ActorAttribution;
|
|
349
|
+
pinnedAt: string;
|
|
350
|
+
reason?: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pinMetaJson(state: PersistedCanvasState, nodeId: string): string | null {
|
|
354
|
+
const meta = state.contextPinMeta?.[nodeId];
|
|
355
|
+
return meta ? JSON.stringify(meta) : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function pinMetaFromRows(rows: Array<{ node_id: string; meta: string | null }>): Record<string, ContextPinMeta> {
|
|
359
|
+
const meta: Record<string, ContextPinMeta> = {};
|
|
360
|
+
for (const row of rows) if (row.meta) meta[row.node_id] = JSON.parse(row.meta) as ContextPinMeta;
|
|
361
|
+
return meta;
|
|
362
|
+
}
|
|
363
|
+
|
|
342
364
|
export interface PersistedRevisionState {
|
|
343
365
|
revision: number;
|
|
344
366
|
floor: number;
|
|
@@ -400,6 +422,9 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
|
|
|
400
422
|
ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
|
|
401
423
|
ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
|
|
402
424
|
ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
|
|
425
|
+
ensureColumn(db, 'context_reads', 'read_nodes', "read_nodes TEXT NOT NULL DEFAULT '{}'");
|
|
426
|
+
ensureColumn(db, 'context_pins', 'meta', 'meta TEXT');
|
|
427
|
+
ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
|
|
403
428
|
ensureColumn(db, 'boards', 'category', 'category TEXT');
|
|
404
429
|
ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
|
|
405
430
|
ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
|
|
@@ -470,6 +495,7 @@ function migrateToBoards(db: Database, dbPath: string, boardName: string): void
|
|
|
470
495
|
if (existing.has(table)) db.exec(`ALTER TABLE ${table} RENAME TO ${table}_v1`);
|
|
471
496
|
}
|
|
472
497
|
if (existing.has('snapshots')) ensureColumn(db, 'snapshots', 'board_id', 'board_id TEXT');
|
|
498
|
+
if (existing.has('snapshot_pins')) ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
|
|
473
499
|
db.exec(SCHEMA_SQL);
|
|
474
500
|
|
|
475
501
|
if (hasBoard) {
|
|
@@ -552,10 +578,10 @@ function snapshotsToBoards(db: Database): void {
|
|
|
552
578
|
`INSERT INTO annotations (board_id, ${annotationColumns}) SELECT ?, ${annotationColumns} FROM snapshot_annotations WHERE snapshot_id = ?`,
|
|
553
579
|
[boardId, snapshot.id],
|
|
554
580
|
);
|
|
555
|
-
db.run(
|
|
556
|
-
|
|
557
|
-
snapshot.id,
|
|
558
|
-
|
|
581
|
+
db.run(
|
|
582
|
+
'INSERT INTO context_pins (board_id, node_id, meta) SELECT ?, node_id, meta FROM snapshot_pins WHERE snapshot_id = ?',
|
|
583
|
+
[boardId, snapshot.id],
|
|
584
|
+
);
|
|
559
585
|
const ax = meta.get('ax_state');
|
|
560
586
|
if (ax) db.run("INSERT INTO ax_state (board_id, key, value) VALUES (?, 'state', ?)", [boardId, ax]);
|
|
561
587
|
deleteSnapshotFromDB(db, snapshot.id);
|
|
@@ -775,10 +801,12 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
|
|
|
775
801
|
}
|
|
776
802
|
deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
|
|
777
803
|
|
|
778
|
-
// Save context pins
|
|
779
|
-
const insertPin = db.prepare(
|
|
804
|
+
// Save context pins with who pinned them
|
|
805
|
+
const insertPin = db.prepare(
|
|
806
|
+
'INSERT INTO context_pins (board_id, node_id, meta) VALUES (?, ?, ?) ON CONFLICT(board_id, node_id) DO UPDATE SET meta = excluded.meta WHERE context_pins.meta IS NOT excluded.meta',
|
|
807
|
+
);
|
|
780
808
|
for (const pinId of state.contextPins) {
|
|
781
|
-
insertPin.run(boardId, pinId);
|
|
809
|
+
insertPin.run(boardId, pinId, pinMetaJson(state, pinId));
|
|
782
810
|
}
|
|
783
811
|
deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
|
|
784
812
|
|
|
@@ -905,9 +933,11 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
|
|
|
905
933
|
// Load context pins
|
|
906
934
|
interface PinRow {
|
|
907
935
|
node_id: string;
|
|
936
|
+
meta: string | null;
|
|
908
937
|
}
|
|
909
|
-
const pinRows = db.query<PinRow, [string]>('SELECT node_id FROM context_pins WHERE board_id = ?').all(id);
|
|
938
|
+
const pinRows = db.query<PinRow, [string]>('SELECT node_id, meta FROM context_pins WHERE board_id = ?').all(id);
|
|
910
939
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
940
|
+
const contextPinMeta = pinMetaFromRows(pinRows);
|
|
911
941
|
|
|
912
942
|
const axRow = db
|
|
913
943
|
.query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
|
|
@@ -925,6 +955,7 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
|
|
|
925
955
|
edges,
|
|
926
956
|
annotations,
|
|
927
957
|
contextPins,
|
|
958
|
+
contextPinMeta,
|
|
928
959
|
ax: parsePersistedAxState(axRow?.value),
|
|
929
960
|
revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
|
|
930
961
|
};
|
|
@@ -1191,9 +1222,9 @@ export function saveSnapshotToDB(
|
|
|
1191
1222
|
}
|
|
1192
1223
|
|
|
1193
1224
|
// Insert snapshot pins
|
|
1194
|
-
const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id) VALUES (?, ?)');
|
|
1225
|
+
const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id, meta) VALUES (?, ?, ?)');
|
|
1195
1226
|
for (const pinId of state.contextPins) {
|
|
1196
|
-
insertPin.run(snapshot.id, pinId);
|
|
1227
|
+
insertPin.run(snapshot.id, pinId, pinMetaJson(state, pinId));
|
|
1197
1228
|
}
|
|
1198
1229
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
1199
1230
|
snapshot.id,
|
|
@@ -1348,11 +1379,13 @@ export function loadSnapshotFromDB(
|
|
|
1348
1379
|
// Load snapshot pins
|
|
1349
1380
|
interface PinRow {
|
|
1350
1381
|
node_id: string;
|
|
1382
|
+
meta: string | null;
|
|
1351
1383
|
}
|
|
1352
1384
|
const pinRows = db
|
|
1353
|
-
.query<PinRow, [string]>('SELECT node_id FROM snapshot_pins WHERE snapshot_id = ?')
|
|
1385
|
+
.query<PinRow, [string]>('SELECT node_id, meta FROM snapshot_pins WHERE snapshot_id = ?')
|
|
1354
1386
|
.all(snapshotRow.id);
|
|
1355
1387
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
1388
|
+
const contextPinMeta = pinMetaFromRows(pinRows);
|
|
1356
1389
|
|
|
1357
1390
|
return {
|
|
1358
1391
|
snapshot,
|
|
@@ -1364,6 +1397,7 @@ export function loadSnapshotFromDB(
|
|
|
1364
1397
|
edges,
|
|
1365
1398
|
annotations,
|
|
1366
1399
|
contextPins,
|
|
1400
|
+
contextPinMeta,
|
|
1367
1401
|
ax: parsePersistedAxState(metaMap.get('ax_state')),
|
|
1368
1402
|
revisionState: metaMap.has('revision_state')
|
|
1369
1403
|
? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CanvasNodeState, CanvasNodeUpdate } from './canvas-state.js';
|
|
2
|
+
|
|
3
|
+
function record(value: unknown): Record<string, unknown> {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function finite(value: unknown): number | undefined {
|
|
8
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Flat fields take precedence over nested aliases; absent axes remain absent. */
|
|
12
|
+
export function resolveCreateGeometry(body: Record<string, unknown>) {
|
|
13
|
+
const position = record(body.position);
|
|
14
|
+
const size = record(body.size);
|
|
15
|
+
return {
|
|
16
|
+
x: finite(body.x) ?? finite(position.x),
|
|
17
|
+
y: finite(body.y) ?? finite(position.y),
|
|
18
|
+
width: finite(body.width) ?? finite(size.width),
|
|
19
|
+
height: finite(body.height) ?? finite(size.height),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Guards and PATCH execution must resolve aliases and single-axis moves identically. */
|
|
24
|
+
export function resolvePatchGeometry(body: Record<string, unknown>, existing: CanvasNodeState) {
|
|
25
|
+
const { x, y, width, height } = resolveCreateGeometry(body);
|
|
26
|
+
return {
|
|
27
|
+
...(x !== undefined || y !== undefined
|
|
28
|
+
? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
|
|
29
|
+
: {}),
|
|
30
|
+
...(width !== undefined || height !== undefined
|
|
31
|
+
? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateCanvasNodePatch(patch: { position?: unknown; size?: unknown }): string | null {
|
|
37
|
+
if (patch.position !== undefined) {
|
|
38
|
+
const position = record(patch.position);
|
|
39
|
+
if (finite(position.x) === undefined || finite(position.y) === undefined) {
|
|
40
|
+
return 'Position must contain finite x and y values.';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (patch.size !== undefined) {
|
|
44
|
+
const size = record(patch.size);
|
|
45
|
+
const width = finite(size.width);
|
|
46
|
+
const height = finite(size.height);
|
|
47
|
+
if (width === undefined || height === undefined) return 'Size must contain finite width and height values.';
|
|
48
|
+
if (width <= 0 || height <= 0) return 'Size width and height must be greater than zero.';
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Invalid patches cannot count as explicit child-position overrides. */
|
|
54
|
+
export function validCanvasNodeUpdates(input: unknown): CanvasNodeUpdate[] {
|
|
55
|
+
if (!Array.isArray(input)) return [];
|
|
56
|
+
return input.filter((value: unknown): value is CanvasNodeUpdate => {
|
|
57
|
+
const update = record(value);
|
|
58
|
+
return (
|
|
59
|
+
typeof update.id === 'string' &&
|
|
60
|
+
(update.collapsed === undefined || typeof update.collapsed === 'boolean') &&
|
|
61
|
+
validateCanvasNodePatch(update) === null
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { mutationHistory } from './mutation-history.js';
|
|
24
24
|
import { intentRegistry } from './intent-registry.js';
|
|
25
25
|
import { clampCreateNodeSize } from './canvas-validation.js';
|
|
26
|
+
import { validCanvasNodeUpdates } from './canvas-geometry.js';
|
|
26
27
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
27
28
|
import { searchNodes } from './spatial-analysis.js';
|
|
28
29
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
@@ -730,26 +731,6 @@ export async function syncCanvasRuntimeBackends(
|
|
|
730
731
|
return { rehydrated, failed };
|
|
731
732
|
}
|
|
732
733
|
|
|
733
|
-
export function validateCanvasNodePatch(patch: {
|
|
734
|
-
position?: { x: number; y: number };
|
|
735
|
-
size?: { width: number; height: number };
|
|
736
|
-
}): string | null {
|
|
737
|
-
if (patch.position) {
|
|
738
|
-
if (!Number.isFinite(patch.position.x) || !Number.isFinite(patch.position.y)) {
|
|
739
|
-
return 'Position must contain finite x and y values.';
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
if (patch.size) {
|
|
743
|
-
if (!Number.isFinite(patch.size.width) || !Number.isFinite(patch.size.height)) {
|
|
744
|
-
return 'Size must contain finite width and height values.';
|
|
745
|
-
}
|
|
746
|
-
if (patch.size.width <= 0 || patch.size.height <= 0) {
|
|
747
|
-
return 'Size width and height must be greater than zero.';
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
return null;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
734
|
let codeGraphTimer: ReturnType<typeof setTimeout> | null = null;
|
|
754
735
|
|
|
755
736
|
function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
|
|
@@ -1406,22 +1387,23 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
|
|
|
1406
1387
|
}
|
|
1407
1388
|
|
|
1408
1389
|
export function applyCanvasNodeUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
|
|
1409
|
-
|
|
1410
|
-
return canvasState.applyUpdates(safe);
|
|
1390
|
+
return canvasState.applyUpdates(validCanvasNodeUpdates(updates));
|
|
1411
1391
|
}
|
|
1412
1392
|
|
|
1413
1393
|
export function setCanvasContextPins(
|
|
1414
1394
|
nodeIds: string[],
|
|
1415
1395
|
mode: CanvasPinMode = 'set',
|
|
1396
|
+
reason?: string,
|
|
1416
1397
|
): { count: number; nodeIds: string[] } {
|
|
1417
1398
|
const normalizePins = (ids: string[]): string[] =>
|
|
1418
1399
|
ids.filter((id, index) => ids.indexOf(id) === index).slice(0, MAX_CONTEXT_PINS);
|
|
1419
1400
|
const normalizedNodeIds = normalizePins(nodeIds);
|
|
1401
|
+
const options = reason ? { reason } : {};
|
|
1420
1402
|
if (mode === 'set') {
|
|
1421
|
-
canvasState.setContextPins(normalizedNodeIds);
|
|
1403
|
+
canvasState.setContextPins(normalizedNodeIds, options);
|
|
1422
1404
|
} else if (mode === 'add') {
|
|
1423
1405
|
const current = Array.from(canvasState.contextPinnedNodeIds);
|
|
1424
|
-
canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]));
|
|
1406
|
+
canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]), options);
|
|
1425
1407
|
} else {
|
|
1426
1408
|
const current = Array.from(canvasState.contextPinnedNodeIds);
|
|
1427
1409
|
canvasState.setContextPins(current.filter((id) => !normalizedNodeIds.includes(id)));
|