pmx-canvas 0.10.0 → 0.12.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/extension.mjs +3 -1
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +688 -128
- package/CHANGELOG.md +69 -0
- package/dist/amp-plugin/pmx-canvas/LICENSE +21 -0
- package/dist/amp-plugin/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/dist/amp-plugin/pmx-canvas/index.js +5445 -0
- package/dist/amp-plugin/pmx-canvas/runtime.json +3 -0
- package/dist/amp-plugin/pmx-canvas/shared-runtime.mjs +194 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/SKILL.md +251 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/evals.json +1086 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/jwt.ts +17 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/login.ts +12 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/auth/middleware.ts +13 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/code-exploration/src/routes/auth.ts +13 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/evals/fixtures/investigation-board/src/handlers/users.ts +27 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/mcp.json +36 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/ax-html-control-surface.md +101 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/codex-app-adapter.md +151 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/excalidraw-diagram-authoring.md +150 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/full-reference.md +1785 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/github-copilot-app-adapter.md +223 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/html-primitives.md +139 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/importing-context.md +132 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/installing-pmx-canvas.md +251 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/tours-and-recording.md +111 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas/references/workbench-workflows.md +900 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-orchestration/SKILL.md +141 -0
- package/dist/amp-plugin/pmx-canvas/skills/pmx-canvas-testing/SKILL.md +266 -0
- package/dist/amp-plugin/pmx-canvas/start-mcp.mjs +14857 -0
- package/dist/amp-plugin/pmx-canvas/tools.json +10963 -0
- package/dist/canvas/chunks/abnfDiagram-N423BO3Z-cwtsds5d.js +1 -0
- package/dist/canvas/chunks/architecture-TIHT7OUA-x63cwejs.js +1 -0
- package/dist/canvas/chunks/architectureDiagram-T3A2C74G-b4xc9tpz.js +36 -0
- package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-ahp3yxxs.js +132 -0
- package/dist/canvas/chunks/c4Diagram-5PPSVZJV-nappnay7.js +10 -0
- package/dist/canvas/chunks/classDiagram-JCYQIIEL-k1j2xc1t.js +1 -0
- package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-33x08xf2.js +1 -0
- package/dist/canvas/chunks/cose-bilkent-JH36ORCC-a428syda.js +1 -0
- package/dist/canvas/chunks/cynefin-VYW2F7L2-8v6jxfz8.js +1 -0
- package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-nspsqebj.js +62 -0
- package/dist/canvas/chunks/dagre-VZM6K2ZE-8y6177fh.js +4 -0
- package/dist/canvas/chunks/diagram-7IWD3JNH-vym028wn.js +30 -0
- package/dist/canvas/chunks/diagram-B4RE2ZJO-jddqb8sy.js +3 -0
- package/dist/canvas/chunks/diagram-LBJQPF4R-bjwbpkrn.js +24 -0
- package/dist/canvas/chunks/diagram-Q27KOJAE-j6ph6hcx.js +24 -0
- package/dist/canvas/chunks/diagram-UB23O5K3-p09w8jej.js +41 -0
- package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-j975ycq5.js +1 -0
- package/dist/canvas/chunks/erDiagram-JOGREHBK-sxp6hjn8.js +85 -0
- package/dist/canvas/chunks/eventmodeling-45OFAUF4-xzfgn6xg.js +1 -0
- package/dist/canvas/chunks/flowDiagram-UKHOOZJN-2nsmkskb.js +1 -0
- package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-4tv230y2.js +292 -0
- package/dist/canvas/chunks/gitGraph-TEB2WS4Q-macbhand.js +1 -0
- package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-mqgmnw9g.js +106 -0
- package/dist/canvas/chunks/index-037z5dz2.js +232 -0
- package/dist/canvas/chunks/index-081n3har.js +207 -0
- package/dist/canvas/chunks/index-5n03dmb9.js +2 -0
- package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
- package/dist/canvas/chunks/index-60pgzdqe.js +157 -0
- package/dist/canvas/chunks/index-6ceya556.js +332 -0
- package/dist/canvas/chunks/index-8g1vb57n.js +2 -0
- package/dist/canvas/chunks/index-8rjes9r6.js +2 -0
- package/dist/canvas/chunks/index-9022d1n9.js +16 -0
- package/dist/canvas/chunks/index-9b02042t.js +2 -0
- package/dist/canvas/chunks/index-ap4q4vez.js +2 -0
- package/dist/canvas/chunks/index-dyp2nhd3.js +2 -0
- package/dist/canvas/chunks/index-frvta4tm.js +2 -0
- package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
- package/dist/canvas/chunks/index-h9ycj8n2.js +2 -0
- package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
- package/dist/canvas/chunks/index-mergmj97.js +2 -0
- package/dist/canvas/chunks/index-nwsya6dj.js +301 -0
- package/dist/canvas/chunks/index-nz8qbd1p.js +2 -0
- package/dist/canvas/chunks/index-qhfrazv3.js +2 -0
- package/dist/canvas/chunks/index-v119nznk.js +4 -0
- package/dist/canvas/chunks/index-vpt37m3d.js +89 -0
- package/dist/canvas/chunks/index-y9ma6azy.js +2 -0
- package/dist/canvas/chunks/index-zx0j3a2z.js +179 -0
- package/dist/canvas/chunks/info-DKCQHKI2-a0ykzs9z.js +1 -0
- package/dist/canvas/chunks/infoDiagram-6WML65LV-qv97qq6a.js +2 -0
- package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-p9306gh0.js +70 -0
- package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-sv67zats.js +139 -0
- package/dist/canvas/chunks/kanban-definition-27J2QSJJ-k2pfam6p.js +89 -0
- package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
- package/dist/canvas/chunks/mermaid.core-83r0fdce.js +12 -0
- package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-vg2newm1.js +96 -0
- package/dist/canvas/chunks/packet-7NZHBO7P-tqnabcdv.js +1 -0
- package/dist/canvas/chunks/pegDiagram-VL7TDLO6-k2h7zc5n.js +1 -0
- package/dist/canvas/chunks/pie-RZYD4A2V-wke6gyhe.js +1 -0
- package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-hg169p1v.js +39 -0
- package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-bxtvsq0b.js +7 -0
- package/dist/canvas/chunks/radar-I7S5WNFK-w7ka0drw.js +1 -0
- package/dist/canvas/chunks/railroad-3IZDKUUU-y3vg318x.js +1 -0
- package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-6hqvcz3c.js +1 -0
- package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-vcae260m.js +1 -0
- package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-0dbyme23.js +1 -0
- package/dist/canvas/chunks/railroadDiagram-AXF67PYL-njyhcp4p.js +1 -0
- package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-2z6v09gm.js +84 -0
- package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-15mzam0e.js +40 -0
- package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-rbshyd2c.js +162 -0
- package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-sg26xwts.js +1 -0
- package/dist/canvas/chunks/stateDiagram-OKZ733FA-6eahp98e.js +1 -0
- package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-tty0ggn5.js +1 -0
- package/dist/canvas/chunks/swimlanes-SLNWSIFB-ze5db10t.js +1 -0
- package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-thze75gq.js +8 -0
- package/dist/canvas/chunks/timeline-definition-Z64GVDOM-vnp7jyy8.js +120 -0
- package/dist/canvas/chunks/treeView-QDETBFTQ-6ysffnm6.js +1 -0
- package/dist/canvas/chunks/treemap-6X3UGDF4-m3h9p066.js +1 -0
- package/dist/canvas/chunks/vennDiagram-T6HMQDX7-0qrnb7mj.js +34 -0
- package/dist/canvas/chunks/wardley-OPB4EBWU-dp37drwx.js +1 -0
- package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-8nxg8wqt.js +78 -0
- package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-nj921e7s.js +7 -0
- package/dist/canvas/global.css +483 -230
- package/dist/canvas/index.js +158 -219
- package/dist/canvas/mcp-app.html +4301 -548
- package/dist/canvas/mermaid-entry.js +338 -338
- package/dist/canvas/surface-theme.css +6 -6
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +49 -49
- package/dist/types/client/canvas/BarHint.d.ts +5 -3
- package/dist/types/client/canvas/BoardMoveDialog.d.ts +2 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
- package/dist/types/client/canvas/EdgeEditor.d.ts +19 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
- package/dist/types/client/canvas/GroupNavigation.d.ts +2 -0
- package/dist/types/client/canvas/TextPrompt.d.ts +4 -0
- package/dist/types/client/canvas/auto-fit.d.ts +4 -5
- package/dist/types/client/mermaid-entry.d.ts +1 -2
- package/dist/types/client/mermaid-render.d.ts +1 -0
- package/dist/types/client/nodes/BoardNode.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
- package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
- package/dist/types/client/nodes/PromptNode.d.ts +1 -0
- package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
- package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
- package/dist/types/client/nodes/WorkCard.d.ts +7 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
- package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
- package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +1 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
- package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +18 -1
- package/dist/types/client/state/human-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +5 -2
- package/dist/types/client/state/presentation.d.ts +17 -6
- package/dist/types/client/state/relation-store.d.ts +11 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -1
- package/dist/types/server/agent-presence.d.ts +1 -0
- package/dist/types/server/ax-interaction.d.ts +1 -0
- package/dist/types/server/ax-state-manager.d.ts +10 -2
- package/dist/types/server/board-local.d.ts +4 -0
- package/dist/types/server/board-move.d.ts +38 -0
- package/dist/types/server/canvas-db.d.ts +12 -2
- package/dist/types/server/canvas-operations.d.ts +5 -6
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +86 -20
- package/dist/types/server/card-relocation.d.ts +6 -0
- package/dist/types/server/context-reads.d.ts +3 -1
- package/dist/types/server/human-presence.d.ts +1 -1
- package/dist/types/server/index.d.ts +26 -10
- package/dist/types/server/intent-registry.d.ts +8 -4
- package/dist/types/server/knowledge-lifecycle.d.ts +4 -2
- package/dist/types/server/mutation-history.d.ts +7 -5
- package/dist/types/server/operations/ops/boards.d.ts +0 -1
- package/dist/types/server/operations/registry.d.ts +2 -0
- package/dist/types/server/tour-control.d.ts +13 -3
- package/dist/types/server/trace-manager.d.ts +1 -0
- package/dist/types/shared/ax-kinds.d.ts +6 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +8 -9
- package/dist/types/shared/human-presence.d.ts +1 -0
- package/dist/types/shared/lifecycle-status.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +8 -0
- package/docs/RELEASE.md +21 -14
- package/docs/agent-plugin.md +16 -2
- package/docs/amp-plugin.md +271 -0
- package/docs/cli.md +42 -3
- package/docs/design/Chrome.dc.html +32 -6
- package/docs/design/GuestName.dc.html +50 -0
- package/docs/design/PluginLogo.svg +7 -0
- package/docs/design/Types1.dc.html +3 -2
- package/docs/design/Types2.dc.html +1 -1
- package/docs/design/Types3.dc.html +1 -1
- package/docs/design/WorkCards.dc.html +39 -0
- package/docs/design/Zoom.dc.html +37 -60
- package/docs/design/canvas.json +17 -1
- package/docs/design/plugin-logo.png +0 -0
- package/docs/design.md +280 -12
- package/docs/evals/agents-md-sections.md +38 -0
- package/docs/http-api.md +179 -11
- package/docs/mcp.md +69 -9
- package/docs/node-types.md +109 -4
- package/docs/plans/plan-009-tech-debt-backlog.md +3 -3
- package/docs/plans/plan-019-post-0.9-roadmap.md +327 -31
- package/docs/plans/plan-020-wave-5-work-planning.md +553 -0
- package/docs/product-vision-2026-09.md +42 -17
- package/docs/sdk.md +18 -0
- package/package.json +7 -4
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +3 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +2 -2
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +688 -128
- package/skills/data-analysis/SKILL.md +13 -26
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +3 -0
- package/skills/pmx-canvas/SKILL.md +232 -849
- package/skills/pmx-canvas/evals/evals.json +26 -3
- package/skills/pmx-canvas/references/full-reference.md +89 -24
- package/skills/pmx-canvas/references/html-primitives.md +7 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +29 -2
- package/skills/pmx-canvas/references/workbench-workflows.md +900 -0
- package/skills/pmx-canvas-testing/SKILL.md +7 -5
- package/skills/published-consumer-e2e/SKILL.md +25 -8
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +51 -77
- package/skills/web-artifacts-builder/LICENSE.txt +202 -0
- package/skills/web-artifacts-builder/SKILL.md +6 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +2 -2
- package/src/cli/commands/apps.ts +1 -0
- package/src/cli/commands/boards.ts +30 -0
- package/src/client/App.tsx +20 -4
- package/src/client/canvas/AttentionHistory.tsx +10 -2
- package/src/client/canvas/BarHint.tsx +6 -3
- package/src/client/canvas/BoardMoveDialog.tsx +225 -0
- package/src/client/canvas/CanvasNode.tsx +79 -49
- package/src/client/canvas/CanvasViewport.tsx +42 -14
- package/src/client/canvas/ContextMenu.tsx +97 -15
- package/src/client/canvas/EdgeEditor.tsx +149 -0
- package/src/client/canvas/EdgeLayer.tsx +441 -174
- package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -2
- package/src/client/canvas/GroupNavigation.tsx +85 -0
- package/src/client/canvas/Presentation.tsx +53 -3
- package/src/client/canvas/RelationsPanel.tsx +9 -0
- package/src/client/canvas/SelectionBar.tsx +21 -0
- package/src/client/canvas/SessionPanel.tsx +35 -1
- package/src/client/canvas/TextPrompt.tsx +18 -1
- package/src/client/canvas/TopBar.tsx +54 -9
- package/src/client/canvas/auto-fit.ts +9 -11
- package/src/client/canvas/canvas-area.ts +2 -2
- package/src/client/canvas/use-focus-trap.ts +6 -1
- package/src/client/mermaid-entry.ts +6 -20
- package/src/client/mermaid-render.ts +39 -0
- package/src/client/nodes/AxStepControls.tsx +45 -69
- package/src/client/nodes/BoardNode.tsx +65 -9
- package/src/client/nodes/ContextNode.tsx +88 -396
- package/src/client/nodes/ExtAppFrame.tsx +302 -1465
- package/src/client/nodes/FileNode.tsx +92 -81
- package/src/client/nodes/HtmlNode.tsx +8 -36
- package/src/client/nodes/LedgerNode.tsx +31 -17
- package/src/client/nodes/MarkdownNode.tsx +14 -24
- package/src/client/nodes/McpAppNode.tsx +48 -154
- package/src/client/nodes/MermaidNode.tsx +80 -69
- package/src/client/nodes/PromptNode.tsx +44 -17
- package/src/client/nodes/ResponseNode.tsx +8 -6
- package/src/client/nodes/StatusNode.tsx +34 -7
- package/src/client/nodes/StatusSummary.tsx +16 -3
- package/src/client/nodes/TraceNode.tsx +8 -8
- package/src/client/nodes/ViewerFrame.tsx +273 -0
- package/src/client/nodes/ViewerHost.tsx +41 -0
- package/src/client/nodes/WebpageNode.tsx +22 -24
- package/src/client/nodes/WorkCard.tsx +175 -0
- package/src/client/nodes/iframe-document-url.ts +20 -27
- package/src/client/nodes/primitive-copy.ts +97 -0
- package/src/client/nodes/use-ax-surface-bridge.ts +18 -14
- package/src/client/nodes/use-iframe-content-height.ts +34 -10
- package/src/client/nodes/use-surface-frame.ts +22 -11
- package/src/client/nodes/viewer-runtime-state.ts +41 -35
- package/src/client/state/attention-bridge.ts +5 -0
- package/src/client/state/attention-store.ts +1 -0
- package/src/client/state/canvas-store.ts +202 -26
- package/src/client/state/human-store.ts +22 -2
- package/src/client/state/intent-bridge.ts +20 -5
- package/src/client/state/presentation.ts +135 -15
- package/src/client/state/relation-store.ts +127 -1
- package/src/client/state/sse-bridge.ts +18 -19
- package/src/client/theme/global.css +483 -230
- package/src/client/theme/surface-theme.css +6 -6
- package/src/client/types.ts +1 -0
- package/src/json-render/charts/tufte-components.tsx +44 -2
- package/src/json-render/renderer/index.css +62 -2
- package/src/json-render/renderer/index.tsx +31 -7
- package/src/json-render/server.ts +3 -2
- package/src/mcp/server.ts +13 -3
- package/src/server/agent-presence.ts +43 -23
- package/src/server/ax-flow-loop.ts +6 -0
- package/src/server/ax-gate-ttl.ts +8 -1
- package/src/server/ax-interaction.ts +16 -10
- package/src/server/ax-state-manager.ts +17 -4
- package/src/server/ax-wait.ts +20 -6
- package/src/server/board-export.ts +54 -30
- package/src/server/board-local.ts +20 -0
- package/src/server/board-move.ts +297 -0
- package/src/server/canvas-db.ts +249 -158
- package/src/server/canvas-geometry.ts +1 -0
- package/src/server/canvas-operations.ts +139 -109
- package/src/server/canvas-schema.ts +21 -0
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +797 -159
- package/src/server/card-relocation.ts +53 -0
- package/src/server/code-graph.ts +4 -3
- package/src/server/context-reads.ts +83 -55
- package/src/server/demo-state.json +140 -114
- package/src/server/file-watcher.ts +30 -10
- package/src/server/html-primitives.ts +3 -9
- package/src/server/html-surface.ts +8 -0
- package/src/server/human-presence.ts +18 -5
- package/src/server/index.ts +54 -24
- package/src/server/intent-registry.ts +43 -5
- package/src/server/knowledge-lifecycle.ts +32 -9
- package/src/server/lifecycle-status.ts +29 -1
- package/src/server/mcp-app-runtime.ts +26 -7
- package/src/server/mutation-history.ts +37 -8
- package/src/server/operations/composites.ts +6 -3
- package/src/server/operations/mcp-contracts.ts +32 -2
- package/src/server/operations/mcp.ts +36 -9
- package/src/server/operations/ops/annotation.ts +22 -1
- package/src/server/operations/ops/ax-await.ts +1 -0
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/batch.ts +25 -11
- package/src/server/operations/ops/boards.ts +45 -6
- package/src/server/operations/ops/nodes.ts +70 -0
- package/src/server/operations/ops/query.ts +32 -3
- package/src/server/operations/ops/snapshots.ts +1 -0
- package/src/server/operations/ops/viewport.ts +11 -9
- package/src/server/operations/registry.ts +49 -4
- package/src/server/server.ts +69 -75
- package/src/server/tour-control.ts +97 -12
- package/src/server/trace-manager.ts +4 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +2 -2
- package/src/shared/agent-context.ts +7 -1
- package/src/shared/auto-arrange.ts +5 -4
- package/src/shared/ax-kinds.ts +13 -0
- package/src/shared/ax-surface-protocol.ts +20 -9
- package/src/shared/content-height-reporter.ts +4 -3
- package/src/shared/human-presence.ts +1 -0
- package/src/shared/lifecycle-status.ts +2 -0
- package/src/shared/tour.ts +8 -0
|
@@ -12,7 +12,17 @@
|
|
|
12
12
|
* the one-shot boot migration into SQLite was retired.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
16
|
import { createHash, randomUUID } from 'node:crypto';
|
|
17
|
+
import {
|
|
18
|
+
prepareBoardMove,
|
|
19
|
+
type BoardMoveInput,
|
|
20
|
+
type BoardMovePreview,
|
|
21
|
+
type BoardMoveResult,
|
|
22
|
+
type MoveBoard,
|
|
23
|
+
} from './board-move.js';
|
|
24
|
+
import { boardMoveBoundary, recordCardRelocation } from './card-relocation.js';
|
|
25
|
+
import { mutationHistory } from './mutation-history.js';
|
|
16
26
|
import {
|
|
17
27
|
DEFAULT_WORKSPACE_SETTINGS,
|
|
18
28
|
workspaceSettingsPatchSchema,
|
|
@@ -43,6 +53,8 @@ import {
|
|
|
43
53
|
openCanvasDb,
|
|
44
54
|
saveStateToDB,
|
|
45
55
|
loadStateFromDB,
|
|
56
|
+
boardStorageVersion,
|
|
57
|
+
assertBoardStorageVersion,
|
|
46
58
|
saveSnapshotToDB,
|
|
47
59
|
loadSnapshotFromDB,
|
|
48
60
|
listSnapshotsFromDB,
|
|
@@ -364,6 +376,7 @@ export interface CanvasNodeUpdate {
|
|
|
364
376
|
position?: { x: number; y: number };
|
|
365
377
|
size?: { width: number; height: number };
|
|
366
378
|
collapsed?: boolean;
|
|
379
|
+
zIndex?: number;
|
|
367
380
|
}
|
|
368
381
|
|
|
369
382
|
export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
|
|
@@ -454,33 +467,232 @@ function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
|
|
|
454
467
|
);
|
|
455
468
|
}
|
|
456
469
|
|
|
470
|
+
/** The mutable, board-owned partition. Workspace services remain on the manager. */
|
|
471
|
+
interface LiveBoardState {
|
|
472
|
+
id: string | null;
|
|
473
|
+
disposed: boolean;
|
|
474
|
+
cleanup: Array<() => void>;
|
|
475
|
+
nodes: Map<string, CanvasNodeState>;
|
|
476
|
+
edges: Map<string, CanvasEdge>;
|
|
477
|
+
annotations: Map<string, CanvasAnnotation>;
|
|
478
|
+
viewport: ViewportState;
|
|
479
|
+
tour: Tour | undefined;
|
|
480
|
+
contextPinnedNodeIds: Set<string>;
|
|
481
|
+
contextPinMeta: Map<string, ContextPinMeta>;
|
|
482
|
+
storageVersion: number;
|
|
483
|
+
contentRevision: number;
|
|
484
|
+
revisionFloor: number;
|
|
485
|
+
deletionTombstones: NodeDeletionTombstone[];
|
|
486
|
+
generation: number;
|
|
487
|
+
saveTimer: ReturnType<typeof setTimeout> | null;
|
|
488
|
+
persistenceError: { message: string; at: string } | null;
|
|
489
|
+
ax: AxStateManager;
|
|
490
|
+
}
|
|
491
|
+
|
|
457
492
|
class CanvasStateManager {
|
|
458
|
-
private
|
|
459
|
-
private
|
|
460
|
-
private
|
|
461
|
-
private
|
|
462
|
-
private
|
|
493
|
+
private readonly boardExecution = new AsyncLocalStorage<LiveBoardState>();
|
|
494
|
+
private readonly liveBoards = new Map<string, LiveBoardState>();
|
|
495
|
+
private nextGeneration = 0;
|
|
496
|
+
private navigationGeneration = 0;
|
|
497
|
+
private readonly hostState: { capability: PmxAxHostCapability | null } = { capability: null };
|
|
498
|
+
private visibleBoard = this.createBoardState();
|
|
499
|
+
|
|
500
|
+
private get boardState(): LiveBoardState {
|
|
501
|
+
const board = this.boardExecution.getStore() ?? this.visibleBoard;
|
|
502
|
+
if (board.disposed) throw new OperationError('The target board was deleted or the workspace was replaced.', 409);
|
|
503
|
+
return board;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Identity for board-owned runtime caches. Never shared by a replacement board. */
|
|
507
|
+
get boardIdentity(): object {
|
|
508
|
+
return this.boardState;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
onBoardDispose(cleanup: () => void): () => void {
|
|
512
|
+
const board = this.boardState;
|
|
513
|
+
board.cleanup.push(cleanup);
|
|
514
|
+
return () => {
|
|
515
|
+
const index = board.cleanup.indexOf(cleanup);
|
|
516
|
+
if (index >= 0) board.cleanup.splice(index, 1);
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private disposeBoardRuntime(board: LiveBoardState): void {
|
|
521
|
+
for (const cleanup of board.cleanup.splice(0)) cleanup();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The human's navigation target, independent of the calling operation's board. */
|
|
525
|
+
get visibleBoardId(): string | null {
|
|
526
|
+
return this.visibleBoard.id;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
get visibleBoardGeneration(): number {
|
|
530
|
+
return this.navigationGeneration;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
get isBackgroundBoard(): boolean {
|
|
534
|
+
return this.boardState !== this.visibleBoard;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Execute on a live board without activating it, retaining ownership across awaits. */
|
|
538
|
+
withBoard<T>(id: string, fn: () => T): T {
|
|
539
|
+
const board = this.loadBoardState(id);
|
|
540
|
+
return this.boardExecution.run(board, fn);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Bind callbacks to their board, rather than the context of whoever invokes them. */
|
|
544
|
+
bindBoard<T extends unknown[], R>(fn: (...args: T) => R): (...args: T) => R {
|
|
545
|
+
const board = this.boardState;
|
|
546
|
+
return (...args) => this.boardExecution.run(board, () => fn(...args));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Viewer/global projections must not inherit the producer's board scope. */
|
|
550
|
+
withVisibleBoard<T>(fn: () => T): T {
|
|
551
|
+
return this.boardExecution.exit(fn);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Sweep resident runtime owners without loading the entire stored library. */
|
|
555
|
+
forEachLiveBoard(fn: () => void): void {
|
|
556
|
+
for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
|
|
557
|
+
this.boardExecution.run(board, fn);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private loadBoardState(id: string): LiveBoardState {
|
|
562
|
+
const cached = this.liveBoards.get(id);
|
|
563
|
+
if (cached && this._db && cached.storageVersion === boardStorageVersion(this._db, id)) return cached;
|
|
564
|
+
if (cached?.saveTimer || cached?.persistenceError)
|
|
565
|
+
throw new OperationError(
|
|
566
|
+
`Board "${id}" changed in another writer. Reconcile unsaved edits before continuing.`,
|
|
567
|
+
409,
|
|
568
|
+
);
|
|
569
|
+
const state = this._db ? this.readProjectedBoard(id) : null;
|
|
570
|
+
if (!state) throw new OperationError(`Board "${id}" not found.`, 404);
|
|
571
|
+
const board = cached ?? this.createBoardState();
|
|
572
|
+
board.id = id;
|
|
573
|
+
this.boardExecution.run(board, () => {
|
|
574
|
+
this.initializePersistedWikiLinks(state, id);
|
|
575
|
+
this.applyPersistedState({ ...state, theme: this._theme });
|
|
576
|
+
});
|
|
577
|
+
this.liveBoards.set(id, board);
|
|
578
|
+
return board;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private createBoardState(): LiveBoardState {
|
|
582
|
+
return {
|
|
583
|
+
id: null,
|
|
584
|
+
disposed: false,
|
|
585
|
+
cleanup: [],
|
|
586
|
+
nodes: new Map(),
|
|
587
|
+
edges: new Map(),
|
|
588
|
+
annotations: new Map(),
|
|
589
|
+
viewport: { x: 0, y: 0, scale: 1 },
|
|
590
|
+
tour: undefined,
|
|
591
|
+
contextPinnedNodeIds: new Set(),
|
|
592
|
+
contextPinMeta: new Map(),
|
|
593
|
+
storageVersion: 0,
|
|
594
|
+
contentRevision: 0,
|
|
595
|
+
revisionFloor: 0,
|
|
596
|
+
deletionTombstones: [],
|
|
597
|
+
generation: ++this.nextGeneration,
|
|
598
|
+
saveTimer: null,
|
|
599
|
+
persistenceError: null,
|
|
600
|
+
ax: new AxStateManager(
|
|
601
|
+
{
|
|
602
|
+
getNodeIds: () => this.currentNodeIdSet(),
|
|
603
|
+
getDb: () => this._db,
|
|
604
|
+
scheduleSave: () => this.scheduleSave(),
|
|
605
|
+
notifyChange: (type) => this.notifyChange(type),
|
|
606
|
+
recordMutation: (info) => this.recordMutation(info),
|
|
607
|
+
suppressed: (fn) => this.suppressed(fn),
|
|
608
|
+
},
|
|
609
|
+
this.hostState,
|
|
610
|
+
),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private get nodes() {
|
|
615
|
+
return this.boardState.nodes;
|
|
616
|
+
}
|
|
617
|
+
private get edges() {
|
|
618
|
+
return this.boardState.edges;
|
|
619
|
+
}
|
|
620
|
+
private get annotations() {
|
|
621
|
+
return this.boardState.annotations;
|
|
622
|
+
}
|
|
623
|
+
private get _viewport() {
|
|
624
|
+
return this.boardState.viewport;
|
|
625
|
+
}
|
|
626
|
+
private set _viewport(value: ViewportState) {
|
|
627
|
+
this.boardState.viewport = value;
|
|
628
|
+
}
|
|
629
|
+
private get _tour() {
|
|
630
|
+
return this.boardState.tour;
|
|
631
|
+
}
|
|
632
|
+
private set _tour(value: Tour | undefined) {
|
|
633
|
+
this.boardState.tour = value;
|
|
634
|
+
}
|
|
635
|
+
private get _contextPinnedNodeIds() {
|
|
636
|
+
return this.boardState.contextPinnedNodeIds;
|
|
637
|
+
}
|
|
638
|
+
private set _contextPinnedNodeIds(value: Set<string>) {
|
|
639
|
+
this.boardState.contextPinnedNodeIds = value;
|
|
640
|
+
}
|
|
641
|
+
private get _contextPinMeta() {
|
|
642
|
+
return this.boardState.contextPinMeta;
|
|
643
|
+
}
|
|
644
|
+
private set _contextPinMeta(value: Map<string, ContextPinMeta>) {
|
|
645
|
+
this.boardState.contextPinMeta = value;
|
|
646
|
+
}
|
|
647
|
+
private get _storageVersion() {
|
|
648
|
+
return this.boardState.storageVersion;
|
|
649
|
+
}
|
|
650
|
+
private set _storageVersion(value: number) {
|
|
651
|
+
this.boardState.storageVersion = value;
|
|
652
|
+
}
|
|
653
|
+
private get _contentRevision() {
|
|
654
|
+
return this.boardState.contentRevision;
|
|
655
|
+
}
|
|
656
|
+
private set _contentRevision(value: number) {
|
|
657
|
+
this.boardState.contentRevision = value;
|
|
658
|
+
}
|
|
659
|
+
private get _revisionFloor() {
|
|
660
|
+
return this.boardState.revisionFloor;
|
|
661
|
+
}
|
|
662
|
+
private set _revisionFloor(value: number) {
|
|
663
|
+
this.boardState.revisionFloor = value;
|
|
664
|
+
}
|
|
665
|
+
private get _deletionTombstones() {
|
|
666
|
+
return this.boardState.deletionTombstones;
|
|
667
|
+
}
|
|
668
|
+
private set _deletionTombstones(value: NodeDeletionTombstone[]) {
|
|
669
|
+
this.boardState.deletionTombstones = value;
|
|
670
|
+
}
|
|
671
|
+
private get _boardGeneration() {
|
|
672
|
+
return this.boardState.generation;
|
|
673
|
+
}
|
|
674
|
+
private set _boardGeneration(value: number) {
|
|
675
|
+
this.boardState.generation = value;
|
|
676
|
+
}
|
|
677
|
+
private get _saveTimer() {
|
|
678
|
+
return this.boardState.saveTimer;
|
|
679
|
+
}
|
|
680
|
+
private set _saveTimer(value: ReturnType<typeof setTimeout> | null) {
|
|
681
|
+
this.boardState.saveTimer = value;
|
|
682
|
+
}
|
|
683
|
+
private get _lastPersistenceError() {
|
|
684
|
+
return this.boardState.persistenceError;
|
|
685
|
+
}
|
|
686
|
+
private set _lastPersistenceError(value: LiveBoardState['persistenceError']) {
|
|
687
|
+
this.boardState.persistenceError = value;
|
|
688
|
+
}
|
|
689
|
+
private get ax() {
|
|
690
|
+
return this.boardState.ax;
|
|
691
|
+
}
|
|
692
|
+
|
|
463
693
|
private _theme: CanvasTheme = 'harbor';
|
|
464
|
-
private _contextPinnedNodeIds = new Set<string>();
|
|
465
|
-
/** Who pinned each pin, when and why; kept only for ids in `_contextPinnedNodeIds`. */
|
|
466
|
-
private _contextPinMeta = new Map<string, ContextPinMeta>();
|
|
467
694
|
private _workspaceRoot = process.cwd();
|
|
468
|
-
private
|
|
469
|
-
private _revisionFloor = 0;
|
|
470
|
-
private _deletionTombstones: NodeDeletionTombstone[] = [];
|
|
471
|
-
|
|
472
|
-
// ── AX state (canvas-bound + timeline + host partitions) ──────────
|
|
473
|
-
// Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
|
|
474
|
-
// holds it and delegates its public AX methods so the SDK/HTTP/MCP surface is
|
|
475
|
-
// byte-stable; the manager receives the host hooks it needs as injected deps.
|
|
476
|
-
private readonly ax = new AxStateManager({
|
|
477
|
-
getNodeIds: () => this.currentNodeIdSet(),
|
|
478
|
-
getDb: () => this._db,
|
|
479
|
-
scheduleSave: () => this.scheduleSave(),
|
|
480
|
-
notifyChange: (type) => this.notifyChange(type),
|
|
481
|
-
recordMutation: (info) => this.recordMutation(info),
|
|
482
|
-
suppressed: (fn) => this.suppressed(fn),
|
|
483
|
-
});
|
|
695
|
+
private moveRuntimeBlockers: () => ReadonlyMap<string, string> = () => new Map();
|
|
484
696
|
|
|
485
697
|
// ── Change listeners (for MCP resource notifications) ──────
|
|
486
698
|
private _changeListeners: ((type: CanvasChangeType) => void)[] = [];
|
|
@@ -492,9 +704,15 @@ class CanvasStateManager {
|
|
|
492
704
|
* — e.g. the long-lived MCP subscription — may ignore the return value).
|
|
493
705
|
*/
|
|
494
706
|
onChange(cb: (type: CanvasChangeType) => void): () => void {
|
|
495
|
-
this.
|
|
707
|
+
const owner = this.boardExecution.getStore();
|
|
708
|
+
const listener = owner
|
|
709
|
+
? (type: CanvasChangeType) => {
|
|
710
|
+
if (owner === this.boardState) this.boardExecution.run(owner, () => cb(type));
|
|
711
|
+
}
|
|
712
|
+
: cb;
|
|
713
|
+
this._changeListeners.push(listener);
|
|
496
714
|
return () => {
|
|
497
|
-
const i = this._changeListeners.indexOf(
|
|
715
|
+
const i = this._changeListeners.indexOf(listener);
|
|
498
716
|
if (i >= 0) this._changeListeners.splice(i, 1);
|
|
499
717
|
};
|
|
500
718
|
}
|
|
@@ -528,6 +746,7 @@ class CanvasStateManager {
|
|
|
528
746
|
}
|
|
529
747
|
|
|
530
748
|
private notifyChange(type: CanvasChangeType): void {
|
|
749
|
+
if (type === 'ax') this.refreshLiveWorkCards();
|
|
531
750
|
// Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
|
|
532
751
|
// itself synchronously here, and splicing the live array mid-iteration would skip
|
|
533
752
|
// the next listener for this notification.
|
|
@@ -640,8 +859,29 @@ class CanvasStateManager {
|
|
|
640
859
|
return { group, childIds, children };
|
|
641
860
|
}
|
|
642
861
|
|
|
643
|
-
private normalizeNode(
|
|
862
|
+
private normalizeNode(
|
|
863
|
+
node: CanvasNodeState,
|
|
864
|
+
workItems?: PmxAxWorkItem[],
|
|
865
|
+
options: { boardId?: string; projectWork?: boolean } = {},
|
|
866
|
+
): CanvasNodeState {
|
|
644
867
|
const data = { ...node.data };
|
|
868
|
+
// These persisted fields are a derived display cache, never a second task record.
|
|
869
|
+
if (options.projectWork !== false && node.type === 'status' && typeof data.workItemId === 'string') {
|
|
870
|
+
const tasks =
|
|
871
|
+
typeof data.workItemBoardId === 'string' && data.workItemBoardId !== options.boardId
|
|
872
|
+
? this.getWorkItemsOnBoard(data.workItemBoardId)
|
|
873
|
+
: (workItems ?? this.getWorkItems());
|
|
874
|
+
const task = tasks.find((item) => item.id === data.workItemId);
|
|
875
|
+
data.title = task?.title ?? 'Task unavailable';
|
|
876
|
+
data.detail = task?.detail ?? '';
|
|
877
|
+
data.liveWorkAvailable = !!task;
|
|
878
|
+
data.liveWorkSource = task?.agentId ?? task?.source ?? '';
|
|
879
|
+
delete data.progress;
|
|
880
|
+
delete data.content;
|
|
881
|
+
delete data.axWorkStatus;
|
|
882
|
+
if (task) data.workState = task.status;
|
|
883
|
+
else delete data.workState;
|
|
884
|
+
}
|
|
645
885
|
// Lifecycle is optional metadata, never a card taxonomy or validation clock.
|
|
646
886
|
delete data.categoryId;
|
|
647
887
|
if (data.contentOrigin !== undefined && data.contentOrigin !== 'authored' && data.contentOrigin !== 'imported') {
|
|
@@ -665,6 +905,104 @@ class CanvasStateManager {
|
|
|
665
905
|
};
|
|
666
906
|
}
|
|
667
907
|
|
|
908
|
+
private refreshLiveWorkCards(): void {
|
|
909
|
+
const owner = this.boardState;
|
|
910
|
+
this.forEachLiveBoard(() => {
|
|
911
|
+
// Derived cache refreshes must not turn a clean stale board into an unsaved conflict.
|
|
912
|
+
if (
|
|
913
|
+
this.boardState !== owner &&
|
|
914
|
+
this._db &&
|
|
915
|
+
this._activeBoardId &&
|
|
916
|
+
!this._saveTimer &&
|
|
917
|
+
!this._lastPersistenceError &&
|
|
918
|
+
this._storageVersion !== boardStorageVersion(this._db, this._activeBoardId)
|
|
919
|
+
)
|
|
920
|
+
return;
|
|
921
|
+
this.refreshCurrentLiveWorkCards();
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private refreshCurrentLiveWorkCards(preserveAttribution = false): void {
|
|
926
|
+
this.withSuppressedRecording(() => {
|
|
927
|
+
const tasks = this.getWorkItems();
|
|
928
|
+
for (const node of this.nodes.values()) {
|
|
929
|
+
if (node.type !== 'status' || typeof node.data.workItemId !== 'string') continue;
|
|
930
|
+
const next = this.normalizeNode(node, tasks);
|
|
931
|
+
if (!this.nodeContentChanged(node, next)) continue;
|
|
932
|
+
if (preserveAttribution) {
|
|
933
|
+
delete next.data.agentSummary;
|
|
934
|
+
delete next.data.summary;
|
|
935
|
+
this.nodes.set(node.id, {
|
|
936
|
+
...next,
|
|
937
|
+
contentRevision: this.nextContentRevision(),
|
|
938
|
+
contentChangedAt: new Date().toISOString(),
|
|
939
|
+
});
|
|
940
|
+
this.scheduleSave();
|
|
941
|
+
} else this.updateNode(node.id, { data: next.data });
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Reconcile a stored display cache and its cursor clock in the same SQLite transaction. */
|
|
947
|
+
private readProjectedBoard(boardId: string): PersistedCanvasState | null {
|
|
948
|
+
const db = this._db!;
|
|
949
|
+
// Validation inside another write must not change its captured versions or
|
|
950
|
+
// publish resident clocks before that outer transaction commits.
|
|
951
|
+
if (db.inTransaction) return loadStateFromDB(db, boardId);
|
|
952
|
+
const projected: CanvasNodeState[] = [];
|
|
953
|
+
let previousVersion = 0;
|
|
954
|
+
const state = db
|
|
955
|
+
.transaction(() => {
|
|
956
|
+
const state = loadStateFromDB(db, boardId);
|
|
957
|
+
if (!state) return null;
|
|
958
|
+
previousVersion = state.storageVersion ?? 0;
|
|
959
|
+
state.revisionState ??= { revision: 0, floor: 0, tombstones: [] };
|
|
960
|
+
const revision = state.revisionState;
|
|
961
|
+
state.nodes = state.nodes.map((node) => {
|
|
962
|
+
if (node.type !== 'status' || typeof node.data.workItemId !== 'string') return node;
|
|
963
|
+
const next = this.normalizeNode(node, state.ax?.workItems ?? [], { boardId });
|
|
964
|
+
if (!this.nodeContentChanged(node, next)) return node;
|
|
965
|
+
delete next.data.agentSummary;
|
|
966
|
+
delete next.data.summary;
|
|
967
|
+
next.contentRevision = ++revision.revision;
|
|
968
|
+
next.contentChangedAt = new Date().toISOString();
|
|
969
|
+
projected.push(next);
|
|
970
|
+
return next;
|
|
971
|
+
});
|
|
972
|
+
if (projected.length) state.storageVersion = saveStateToDB(db, boardId, state);
|
|
973
|
+
return state;
|
|
974
|
+
})
|
|
975
|
+
.immediate();
|
|
976
|
+
const live = this.liveBoards.get(boardId);
|
|
977
|
+
if (
|
|
978
|
+
state &&
|
|
979
|
+
projected.length &&
|
|
980
|
+
live &&
|
|
981
|
+
!live.saveTimer &&
|
|
982
|
+
!live.persistenceError &&
|
|
983
|
+
live.storageVersion === previousVersion
|
|
984
|
+
) {
|
|
985
|
+
// Only the derived cache changed. Do not reinstall AX or renew approval TTLs.
|
|
986
|
+
this.boardExecution.run(live, () => {
|
|
987
|
+
for (const node of projected) this.nodes.set(node.id, structuredClone(node));
|
|
988
|
+
this._contentRevision = state.revisionState!.revision;
|
|
989
|
+
this._storageVersion = state.storageVersion!;
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
return state;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Call before capturing a context snapshot, never from per-node getters used during writes. */
|
|
996
|
+
refreshContextWorkCards(): void {
|
|
997
|
+
if (!this._db || !this._activeBoardId) return;
|
|
998
|
+
if (this._saveTimer || this._lastPersistenceError) {
|
|
999
|
+
this.refreshCurrentLiveWorkCards(true);
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const state = this.readProjectedBoard(this._activeBoardId);
|
|
1003
|
+
if (state && state.storageVersion !== this._storageVersion) this.applyPersistedState(state, false);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
668
1006
|
private nextContentRevision(): number {
|
|
669
1007
|
this._contentRevision += 1;
|
|
670
1008
|
return this._contentRevision;
|
|
@@ -705,7 +1043,9 @@ class CanvasStateManager {
|
|
|
705
1043
|
}
|
|
706
1044
|
|
|
707
1045
|
private nodeForRead(node: CanvasNodeState): CanvasNodeState {
|
|
708
|
-
const resolved = this.resolveNodeDataBlobs(
|
|
1046
|
+
const resolved = this.resolveNodeDataBlobs(
|
|
1047
|
+
node.type === 'status' && typeof node.data.workItemId === 'string' ? this.normalizeNode(node) : node,
|
|
1048
|
+
);
|
|
709
1049
|
return {
|
|
710
1050
|
...resolved,
|
|
711
1051
|
pinned: resolved.pinned || this._contextPinnedNodeIds.has(resolved.id),
|
|
@@ -808,7 +1148,6 @@ class CanvasStateManager {
|
|
|
808
1148
|
// ── Persistence ────────────────────────────────────────────
|
|
809
1149
|
private _db: import('bun:sqlite').Database | null = null;
|
|
810
1150
|
private _dbPath: string | null = null;
|
|
811
|
-
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
812
1151
|
readonly knowledge = new KnowledgeLifecycle(
|
|
813
1152
|
() => this._db,
|
|
814
1153
|
() => this.notifyChange('nodes'),
|
|
@@ -1038,7 +1377,7 @@ class CanvasStateManager {
|
|
|
1038
1377
|
if (this._db) {
|
|
1039
1378
|
try {
|
|
1040
1379
|
const boardId = getActiveBoardIdFromDB(this._db);
|
|
1041
|
-
const state = boardId ?
|
|
1380
|
+
const state = boardId ? this.readProjectedBoard(boardId) : null;
|
|
1042
1381
|
if (boardId && state) {
|
|
1043
1382
|
this.initializePersistedWikiLinks(state, boardId);
|
|
1044
1383
|
this.applyPersistedState(state);
|
|
@@ -1129,15 +1468,24 @@ class CanvasStateManager {
|
|
|
1129
1468
|
|
|
1130
1469
|
// ── Boards ──────────────────────────────────────────────────
|
|
1131
1470
|
|
|
1132
|
-
private _activeBoardId: string | null
|
|
1133
|
-
|
|
1471
|
+
private get _activeBoardId(): string | null {
|
|
1472
|
+
return this.boardState.id;
|
|
1473
|
+
}
|
|
1474
|
+
private set _activeBoardId(id: string | null) {
|
|
1475
|
+
this.boardState.id = id;
|
|
1476
|
+
if (id) this.liveBoards.set(id, this.boardState);
|
|
1477
|
+
}
|
|
1134
1478
|
|
|
1135
1479
|
/** Invalidates asynchronous work whenever the loaded board state is replaced. */
|
|
1136
1480
|
get boardGeneration(): number {
|
|
1481
|
+
return this.boardExecution.getStore() ? this._boardGeneration : this.navigationGeneration;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
get boardLifetimeGeneration(): number {
|
|
1137
1485
|
return this._boardGeneration;
|
|
1138
1486
|
}
|
|
1139
1487
|
|
|
1140
|
-
/** The
|
|
1488
|
+
/** The execution board, or the open board outside a scoped operation. */
|
|
1141
1489
|
get activeBoardId(): string | null {
|
|
1142
1490
|
return this._activeBoardId;
|
|
1143
1491
|
}
|
|
@@ -1343,62 +1691,67 @@ class CanvasStateManager {
|
|
|
1343
1691
|
let before: PersistedCanvasState | undefined;
|
|
1344
1692
|
const activeLinkRevisions = new Map<string, number>();
|
|
1345
1693
|
let activeRevision = this._contentRevision;
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
// without publishing or changing the active board before commit.
|
|
1362
|
-
if (this._activeBoardId) saveStateToDB(db, this._activeBoardId, this.persistedState());
|
|
1363
|
-
const state = loadStateFromDB(db, boardId);
|
|
1364
|
-
if (!state) throw new OperationError(`Board "${boardId}" not found.`, 404);
|
|
1365
|
-
const policies = [this.getPolicy(), state.ax?.policy];
|
|
1366
|
-
if (policies.some((policy) => policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length)))
|
|
1367
|
-
throw new OperationError('Destination/retry creation is refused while either board has a scope fence.', 403);
|
|
1368
|
-
state.revisionState = {
|
|
1369
|
-
...state.revisionState,
|
|
1370
|
-
revision: state.revisionState?.revision ?? 0,
|
|
1371
|
-
floor: state.revisionState?.floor ?? 0,
|
|
1372
|
-
tombstones: state.revisionState?.tombstones ?? [],
|
|
1373
|
-
};
|
|
1374
|
-
before = structuredClone(state);
|
|
1375
|
-
const invalidations: Array<[string, string]> = [];
|
|
1376
|
-
let next = build(state, (targetBoardId, nodeId) => invalidations.push([targetBoardId, nodeId]));
|
|
1377
|
-
// Theme is workspace-global; never let an inactive projection overwrite it.
|
|
1378
|
-
state.theme = this._theme;
|
|
1379
|
-
saveStateToDB(db, boardId, state);
|
|
1380
|
-
const invalidate = (targetBoardId: string, targetNodeId: string) => {
|
|
1381
|
-
const revision = invalidateLinksInDB(db, targetBoardId, targetNodeId);
|
|
1382
|
-
if (targetBoardId === this._activeBoardId && revision !== undefined) {
|
|
1383
|
-
activeLinkRevisions.set(targetNodeId, revision);
|
|
1384
|
-
activeRevision = Math.max(activeRevision, revision);
|
|
1694
|
+
let activeStorageVersion = this._storageVersion;
|
|
1695
|
+
const result = db
|
|
1696
|
+
.transaction(() => {
|
|
1697
|
+
const previous = requestId
|
|
1698
|
+
? db
|
|
1699
|
+
.query<{ board_id: string; fingerprint: string; node: string }, [string]>(
|
|
1700
|
+
'SELECT board_id, fingerprint, node FROM node_creation_requests WHERE request_id = ?',
|
|
1701
|
+
)
|
|
1702
|
+
.get(requestId)
|
|
1703
|
+
: null;
|
|
1704
|
+
if (previous) {
|
|
1705
|
+
if (previous.board_id !== boardId || previous.fingerprint !== fingerprint)
|
|
1706
|
+
throw new OperationError('requestId was already used with different creation input or destination.', 409);
|
|
1707
|
+
// Return the original receipt even if the node has since been edited or removed.
|
|
1708
|
+
return JSON.parse(previous.node) as T;
|
|
1385
1709
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1710
|
+
// Include pending edits in catalog resolution and durable relation clocks,
|
|
1711
|
+
// without publishing or changing the active board before commit.
|
|
1712
|
+
this.stageLiveBoards(db);
|
|
1713
|
+
const state = loadStateFromDB(db, boardId);
|
|
1714
|
+
if (!state) throw new OperationError(`Board "${boardId}" not found.`, 404);
|
|
1715
|
+
const policies = [this.getPolicy(), state.ax?.policy];
|
|
1716
|
+
if (policies.some((policy) => policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length)))
|
|
1717
|
+
throw new OperationError('Destination/retry creation is refused while either board has a scope fence.', 403);
|
|
1718
|
+
state.revisionState = {
|
|
1719
|
+
...state.revisionState,
|
|
1720
|
+
revision: state.revisionState?.revision ?? 0,
|
|
1721
|
+
floor: state.revisionState?.floor ?? 0,
|
|
1722
|
+
tombstones: state.revisionState?.tombstones ?? [],
|
|
1723
|
+
};
|
|
1724
|
+
before = structuredClone(state);
|
|
1725
|
+
const invalidations: Array<[string, string]> = [];
|
|
1726
|
+
let next = build(state, (targetBoardId, nodeId) => invalidations.push([targetBoardId, nodeId]));
|
|
1727
|
+
// Theme is workspace-global; never let an inactive projection overwrite it.
|
|
1728
|
+
state.theme = this._theme;
|
|
1729
|
+
saveStateToDB(db, boardId, state);
|
|
1730
|
+
const invalidate = (targetBoardId: string, targetNodeId: string) => {
|
|
1731
|
+
const revision = invalidateLinksInDB(db, targetBoardId, targetNodeId);
|
|
1732
|
+
if (targetBoardId === this._activeBoardId && revision !== undefined) {
|
|
1733
|
+
activeLinkRevisions.set(targetNodeId, revision);
|
|
1734
|
+
activeRevision = Math.max(activeRevision, revision);
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
for (const [targetBoardId, nodeId] of invalidations) invalidate(targetBoardId, nodeId);
|
|
1738
|
+
if ('data' in next) invalidateIncomingLinksInDB(db, boardId, next.id, invalidate);
|
|
1739
|
+
committed = loadStateFromDB(db, boardId)!;
|
|
1740
|
+
next = ([...committed.nodes, ...committed.edges].find((candidate) => candidate.id === next.id) ?? next) as T;
|
|
1741
|
+
if (active) activeRevision = committed.revisionState!.revision;
|
|
1742
|
+
if (this._activeBoardId) activeStorageVersion = boardStorageVersion(db, this._activeBoardId);
|
|
1743
|
+
if (requestId)
|
|
1744
|
+
db.run('INSERT INTO node_creation_requests VALUES (?, ?, ?, ?)', [
|
|
1745
|
+
requestId,
|
|
1746
|
+
boardId,
|
|
1747
|
+
fingerprint,
|
|
1748
|
+
JSON.stringify(next),
|
|
1749
|
+
]);
|
|
1750
|
+
return next;
|
|
1751
|
+
})
|
|
1752
|
+
.immediate();
|
|
1401
1753
|
if (committed) {
|
|
1754
|
+
this._storageVersion = activeStorageVersion;
|
|
1402
1755
|
if (active) {
|
|
1403
1756
|
const changed = committed.nodes.filter(
|
|
1404
1757
|
(node) => JSON.stringify(before!.nodes.find((old) => old.id === node.id)) !== JSON.stringify(node),
|
|
@@ -1442,16 +1795,48 @@ class CanvasStateManager {
|
|
|
1442
1795
|
}
|
|
1443
1796
|
this._contentRevision = activeRevision;
|
|
1444
1797
|
if (active || activeLinkRevisions.size) this.notifyChange('nodes');
|
|
1798
|
+
this.reconcileStoredBoards();
|
|
1445
1799
|
}
|
|
1446
1800
|
return result;
|
|
1447
1801
|
}
|
|
1448
1802
|
|
|
1803
|
+
/** Called inside a transaction: publish no storage clocks until it commits. */
|
|
1804
|
+
private stageLiveBoards(db: import('bun:sqlite').Database): void {
|
|
1805
|
+
for (const board of this.liveBoards.values()) {
|
|
1806
|
+
if (board !== this.boardState && !board.saveTimer && !board.persistenceError) continue;
|
|
1807
|
+
this.boardExecution.run(board, () => saveStateToDB(db, board.id!, this.persistedState()));
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** A stored multi-board write retains each live owner's runtime and camera. */
|
|
1812
|
+
private reconcileStoredBoards(ids: Iterable<string> = this.liveBoards.keys()): void {
|
|
1813
|
+
if (!this._db) return;
|
|
1814
|
+
for (const id of ids) {
|
|
1815
|
+
const board = this.liveBoards.get(id);
|
|
1816
|
+
if (!board) continue;
|
|
1817
|
+
if (board.storageVersion === boardStorageVersion(this._db, board.id!)) continue;
|
|
1818
|
+
const state = loadStateFromDB(this._db, board.id!)!;
|
|
1819
|
+
this.boardExecution.run(board, () => {
|
|
1820
|
+
if (board.saveTimer) clearTimeout(board.saveTimer);
|
|
1821
|
+
board.saveTimer = null;
|
|
1822
|
+
board.persistenceError = null;
|
|
1823
|
+
this.applyPersistedState(state, false);
|
|
1824
|
+
this.notifyChange('nodes');
|
|
1825
|
+
this.notifyChange('pins');
|
|
1826
|
+
this.notifyChange('ax');
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1449
1831
|
/** Read a board without activating it. This projection is not a write payload. */
|
|
1450
1832
|
readBoard(
|
|
1451
1833
|
id: string,
|
|
1452
1834
|
includeBlobs = true,
|
|
1453
1835
|
): { board: CanvasBoard; state: PersistedCanvasState; layout: CanvasLayout } | null {
|
|
1454
1836
|
if (!this._db) return null;
|
|
1837
|
+
const live = this.liveBoards.get(id);
|
|
1838
|
+
if (id !== this._activeBoardId && live && (live.saveTimer || live.persistenceError))
|
|
1839
|
+
return this.boardExecution.run(live, () => this.readBoard(id, includeBlobs));
|
|
1455
1840
|
const board = this.listBoards().find((entry) => entry.id === id);
|
|
1456
1841
|
if (!board) return null;
|
|
1457
1842
|
if (id === this._activeBoardId) {
|
|
@@ -1465,6 +1850,7 @@ class CanvasStateManager {
|
|
|
1465
1850
|
board,
|
|
1466
1851
|
layout,
|
|
1467
1852
|
state: {
|
|
1853
|
+
storageVersion: this._storageVersion,
|
|
1468
1854
|
version: 1,
|
|
1469
1855
|
theme: this._theme,
|
|
1470
1856
|
tour: layout.tour,
|
|
@@ -1479,12 +1865,16 @@ class CanvasStateManager {
|
|
|
1479
1865
|
},
|
|
1480
1866
|
};
|
|
1481
1867
|
}
|
|
1482
|
-
const state =
|
|
1868
|
+
const state = this.readProjectedBoard(id);
|
|
1483
1869
|
if (!state) return null;
|
|
1484
1870
|
this.initializePersistedWikiLinks(state, id);
|
|
1485
1871
|
const pins = new Set(state.contextPins);
|
|
1486
1872
|
state.nodes = state.nodes.map((node) => ({
|
|
1487
|
-
...(
|
|
1873
|
+
...(node.type === 'status' && typeof node.data.workItemId === 'string'
|
|
1874
|
+
? this.normalizeNode(node, state.ax?.workItems ?? [], { boardId: id })
|
|
1875
|
+
: includeBlobs
|
|
1876
|
+
? this.resolveNodeDataBlobs(node)
|
|
1877
|
+
: node),
|
|
1488
1878
|
pinned: node.pinned || pins.has(node.id),
|
|
1489
1879
|
}));
|
|
1490
1880
|
return {
|
|
@@ -1504,9 +1894,10 @@ class CanvasStateManager {
|
|
|
1504
1894
|
/** Every board in the workspace, most recently opened first. */
|
|
1505
1895
|
listBoards(): CanvasBoard[] {
|
|
1506
1896
|
if (!this._db) return [];
|
|
1507
|
-
return listBoardsFromDB(this._db).map((board) =>
|
|
1508
|
-
|
|
1509
|
-
|
|
1897
|
+
return listBoardsFromDB(this._db).map((board) => {
|
|
1898
|
+
const live = this.liveBoards.get(board.id);
|
|
1899
|
+
return live ? { ...board, nodeCount: live.nodes.size } : board;
|
|
1900
|
+
});
|
|
1510
1901
|
}
|
|
1511
1902
|
|
|
1512
1903
|
storeAttachment(input: { boardId: string; name: string; mime: string; bytes: Uint8Array }): Attachment | null {
|
|
@@ -1549,10 +1940,15 @@ class CanvasStateManager {
|
|
|
1549
1940
|
clearTimeout(this._saveTimer);
|
|
1550
1941
|
this._saveTimer = null;
|
|
1551
1942
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1943
|
+
let storageVersion = this._storageVersion;
|
|
1944
|
+
const result = this._db
|
|
1945
|
+
.transaction(() => {
|
|
1946
|
+
if (this._activeBoardId) storageVersion = saveStateToDB(this._db!, this._activeBoardId, this.persistedState());
|
|
1947
|
+
return updateImport(this._db!, id, 'committed', { committedNodeIds: nodeIds });
|
|
1948
|
+
})
|
|
1949
|
+
.immediate();
|
|
1950
|
+
this._storageVersion = storageVersion;
|
|
1951
|
+
return result;
|
|
1556
1952
|
}
|
|
1557
1953
|
|
|
1558
1954
|
getContextBriefCursor(boardId: string, consumer: string): number | null {
|
|
@@ -1571,6 +1967,159 @@ class CanvasStateManager {
|
|
|
1571
1967
|
return createBoardInDB(this._db, name, folder);
|
|
1572
1968
|
}
|
|
1573
1969
|
|
|
1970
|
+
/** The host supplies runtime ownership, not another copy of board state. */
|
|
1971
|
+
setMoveRuntimeBlockers(read: () => ReadonlyMap<string, string>): void {
|
|
1972
|
+
this.moveRuntimeBlockers = read;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
moveSelectionToNewBoard(
|
|
1976
|
+
input: BoardMoveInput,
|
|
1977
|
+
): { preview: BoardMovePreview } | { result: BoardMoveResult; replayed: boolean } {
|
|
1978
|
+
const db = this._db;
|
|
1979
|
+
if (!db) throw new OperationError('Moving cards requires persistent workspace storage.', 409);
|
|
1980
|
+
const name = input.name.trim();
|
|
1981
|
+
if (!name || name.length > 120) throw new OperationError('Board names must be 1–120 characters.');
|
|
1982
|
+
if (!input.requestId.trim() || input.requestId.length > 200)
|
|
1983
|
+
throw new OperationError('A move requires a requestId of 1–200 characters.');
|
|
1984
|
+
const normalized = { ...input, name, nodeIds: [...new Set(input.nodeIds)].sort() };
|
|
1985
|
+
const fingerprint = JSON.stringify({
|
|
1986
|
+
sourceBoardId: input.sourceBoardId,
|
|
1987
|
+
nodeIds: normalized.nodeIds,
|
|
1988
|
+
name,
|
|
1989
|
+
expectedRevision: input.expectedRevision,
|
|
1990
|
+
});
|
|
1991
|
+
let committed: PersistedCanvasState | undefined;
|
|
1992
|
+
const perform = () => {
|
|
1993
|
+
const previous = db
|
|
1994
|
+
.query<{ fingerprint: string; result: string }, [string]>(
|
|
1995
|
+
'SELECT fingerprint, result FROM board_moves WHERE request_id = ?',
|
|
1996
|
+
)
|
|
1997
|
+
.get(input.requestId);
|
|
1998
|
+
if (previous && !input.preview) {
|
|
1999
|
+
if (previous.fingerprint !== fingerprint)
|
|
2000
|
+
throw new OperationError('requestId was already used for a different move.', 409);
|
|
2001
|
+
return { result: JSON.parse(previous.result) as BoardMoveResult, replayed: true };
|
|
2002
|
+
}
|
|
2003
|
+
if (input.sourceBoardId !== this._activeBoardId)
|
|
2004
|
+
throw new OperationError('The source board is no longer open. Preview the move again.', 409);
|
|
2005
|
+
assertBoardStorageVersion(db, input.sourceBoardId, this._storageVersion);
|
|
2006
|
+
const library = new Map<string, MoveBoard>();
|
|
2007
|
+
for (const board of listBoardsFromDB(db)) {
|
|
2008
|
+
const live = this.liveBoards.get(board.id);
|
|
2009
|
+
const state =
|
|
2010
|
+
live && (board.id === input.sourceBoardId || live.saveTimer || live.persistenceError)
|
|
2011
|
+
? this.boardExecution.run(live, () => this.persistedState(false))
|
|
2012
|
+
: loadStateFromDB(db, board.id)!;
|
|
2013
|
+
state.nodes = state.nodes.map((node) => this.resolveNodeDataBlobs(node));
|
|
2014
|
+
library.set(board.id, { board, state });
|
|
2015
|
+
}
|
|
2016
|
+
const source = library.get(input.sourceBoardId)!;
|
|
2017
|
+
const destinationId = `board-${randomUUID()}`;
|
|
2018
|
+
const portalId = `board-portal-${randomUUID()}`;
|
|
2019
|
+
const at = new Date().toISOString();
|
|
2020
|
+
const plan = prepareBoardMove(
|
|
2021
|
+
normalized,
|
|
2022
|
+
library,
|
|
2023
|
+
destinationId,
|
|
2024
|
+
portalId,
|
|
2025
|
+
listImports(db, input.sourceBoardId),
|
|
2026
|
+
this.moveRuntimeBlockers(),
|
|
2027
|
+
currentActor(),
|
|
2028
|
+
at,
|
|
2029
|
+
);
|
|
2030
|
+
for (const id of plan.preview.affectedBoardIds) {
|
|
2031
|
+
const policy = library.get(id)!.state.ax?.policy;
|
|
2032
|
+
if (policy && (policy.scope || Object.keys(policy.agentScopes ?? {}).length))
|
|
2033
|
+
plan.preview.blockers.push(`${id}: clear the scope fence before relocating board-owned cards/references`);
|
|
2034
|
+
}
|
|
2035
|
+
for (const [id, state] of plan.states) {
|
|
2036
|
+
const original = library.get(id)?.state;
|
|
2037
|
+
if (!original) continue;
|
|
2038
|
+
const locked = humanPresence.lockedNodes(Date.now(), id);
|
|
2039
|
+
for (const node of original.nodes)
|
|
2040
|
+
if (
|
|
2041
|
+
locked.has(node.id) &&
|
|
2042
|
+
JSON.stringify(node) !== JSON.stringify(state.nodes.find((next) => next.id === node.id))
|
|
2043
|
+
)
|
|
2044
|
+
plan.preview.blockers.push(`${node.id}: being edited by ${locked.get(node.id)}`);
|
|
2045
|
+
}
|
|
2046
|
+
if (input.preview) return { preview: plan.preview };
|
|
2047
|
+
if (plan.preview.blockers.length)
|
|
2048
|
+
throw new OperationError('Move refused. Resolve the listed bindings, then preview again.', 409, {
|
|
2049
|
+
blockers: plan.preview.blockers,
|
|
2050
|
+
});
|
|
2051
|
+
db.run(
|
|
2052
|
+
'INSERT INTO boards (id, name, folder, created_at, relation_vocabulary, readme_node_id) VALUES (?, ?, ?, ?, ?, ?)',
|
|
2053
|
+
[
|
|
2054
|
+
destinationId,
|
|
2055
|
+
name,
|
|
2056
|
+
source.board.folder,
|
|
2057
|
+
at,
|
|
2058
|
+
JSON.stringify(source.board.relationVocabulary ?? null),
|
|
2059
|
+
plan.preview.readmeNodeId,
|
|
2060
|
+
],
|
|
2061
|
+
);
|
|
2062
|
+
for (const [id, state] of plan.states) {
|
|
2063
|
+
state.theme = this._theme;
|
|
2064
|
+
saveStateToDB(db, id, this.externalizePersistedStateBlobs(state));
|
|
2065
|
+
db.run(
|
|
2066
|
+
'INSERT INTO board_move_boundaries VALUES (?, ?) ON CONFLICT(board_id) DO UPDATE SET request_id = excluded.request_id',
|
|
2067
|
+
[id, input.requestId],
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
if (plan.preview.readmeNodeId) updateBoardInDB(db, input.sourceBoardId, { readmeNodeId: null });
|
|
2071
|
+
for (const attachment of plan.preview.attachmentIds) {
|
|
2072
|
+
db.run('UPDATE attachments SET board_id = ? WHERE id = ? AND board_id = ?', [
|
|
2073
|
+
destinationId,
|
|
2074
|
+
attachment,
|
|
2075
|
+
input.sourceBoardId,
|
|
2076
|
+
]);
|
|
2077
|
+
db.run('UPDATE document_imports SET board_id = ? WHERE attachment_id = ? AND board_id = ?', [
|
|
2078
|
+
destinationId,
|
|
2079
|
+
attachment,
|
|
2080
|
+
input.sourceBoardId,
|
|
2081
|
+
]);
|
|
2082
|
+
}
|
|
2083
|
+
recordCardRelocation(db, input.requestId, input.sourceBoardId, destinationId, plan.preview.nodeIds);
|
|
2084
|
+
const result: BoardMoveResult = {
|
|
2085
|
+
sourceBoardId: input.sourceBoardId,
|
|
2086
|
+
destinationBoardId: destinationId,
|
|
2087
|
+
portalId,
|
|
2088
|
+
nodeIds: plan.preview.nodeIds,
|
|
2089
|
+
affectedBoardIds: [...plan.preview.affectedBoardIds, destinationId],
|
|
2090
|
+
};
|
|
2091
|
+
db.run('INSERT INTO board_moves VALUES (?, ?, ?, ?)', [input.requestId, fingerprint, JSON.stringify(result), at]);
|
|
2092
|
+
committed = loadStateFromDB(db, input.sourceBoardId)!;
|
|
2093
|
+
return { result, replayed: false };
|
|
2094
|
+
};
|
|
2095
|
+
const transaction = db.transaction(perform);
|
|
2096
|
+
const result = input.preview ? transaction() : transaction.immediate();
|
|
2097
|
+
if (committed) {
|
|
2098
|
+
// This is not a board activation: retain the camera, runtime partition and
|
|
2099
|
+
// mounted viewers. Only the committed nodes, relations and pins changed.
|
|
2100
|
+
const retained = new Set(committed.nodes.map((node) => node.id));
|
|
2101
|
+
const removed = [...this.nodes.keys()].filter((id) => !retained.has(id));
|
|
2102
|
+
this.nodes.clear();
|
|
2103
|
+
for (const node of committed.nodes) this.nodes.set(node.id, this.resolveNodeDataBlobs(node));
|
|
2104
|
+
this.edges.clear();
|
|
2105
|
+
for (const edge of committed.edges) this.edges.set(edge.id, edge);
|
|
2106
|
+
this._contextPinnedNodeIds = new Set(committed.contextPins);
|
|
2107
|
+
this._contextPinMeta = new Map(Object.entries(committed.contextPinMeta ?? {}));
|
|
2108
|
+
this._storageVersion = committed.storageVersion!;
|
|
2109
|
+
this._contentRevision = committed.revisionState!.revision;
|
|
2110
|
+
this._revisionFloor = committed.revisionState!.floor;
|
|
2111
|
+
this._deletionTombstones = [];
|
|
2112
|
+
for (const id of removed) this.ax.revalidateAfterNodeRemoval(id);
|
|
2113
|
+
mutationHistory.reset();
|
|
2114
|
+
this._lastPersistenceError = null;
|
|
2115
|
+
this.notifyChange('nodes');
|
|
2116
|
+
this.notifyChange('pins');
|
|
2117
|
+
this.notifyChange('ax');
|
|
2118
|
+
if (result.result) this.reconcileStoredBoards(result.result.affectedBoardIds);
|
|
2119
|
+
}
|
|
2120
|
+
return result;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1574
2123
|
updateBoard(
|
|
1575
2124
|
id: string,
|
|
1576
2125
|
patch: Pick<Parameters<typeof updateBoardInDB>[2], 'name' | 'folder' | 'mapPosition' | 'relationVocabulary'>,
|
|
@@ -1663,6 +2212,9 @@ class CanvasStateManager {
|
|
|
1663
2212
|
delete data.axStep;
|
|
1664
2213
|
delete data.axWorkStatus;
|
|
1665
2214
|
delete data.workItemId;
|
|
2215
|
+
delete data.workItemBoardId;
|
|
2216
|
+
delete data.liveWorkAvailable;
|
|
2217
|
+
delete data.liveWorkSource;
|
|
1666
2218
|
delete data.approvalGateId;
|
|
1667
2219
|
delete data.parentGroup;
|
|
1668
2220
|
if (typeof node.data.parentGroup === 'string' && ids.has(node.data.parentGroup)) {
|
|
@@ -1769,35 +2321,35 @@ class CanvasStateManager {
|
|
|
1769
2321
|
/** Deletes a board and its snapshots; deleting the open board returns to Home first. */
|
|
1770
2322
|
deleteBoard(id: string): boolean {
|
|
1771
2323
|
if (!this._db) return false;
|
|
1772
|
-
if (id === this.
|
|
2324
|
+
if (id === this.visibleBoard.id && !this.withVisibleBoard(() => this.switchBoard(null))) return false;
|
|
1773
2325
|
// Reverse relations must include outgoing links still debounced on the open board.
|
|
1774
|
-
|
|
1775
|
-
|
|
2326
|
+
this.flushToDisk();
|
|
2327
|
+
const removed = deleteBoardFromDB(this._db, id, (boardId, nodeId) =>
|
|
2328
|
+
this.invalidateRelationTarget(boardId, nodeId),
|
|
2329
|
+
);
|
|
2330
|
+
if (removed) {
|
|
2331
|
+
const board = this.liveBoards.get(id);
|
|
2332
|
+
if (board) {
|
|
2333
|
+
board.disposed = true;
|
|
2334
|
+
this.disposeBoardRuntime(board);
|
|
2335
|
+
}
|
|
2336
|
+
this.liveBoards.delete(id);
|
|
2337
|
+
}
|
|
2338
|
+
return removed;
|
|
1776
2339
|
}
|
|
1777
2340
|
|
|
1778
2341
|
/**
|
|
1779
|
-
* Save
|
|
1780
|
-
*
|
|
1781
|
-
* sessions, watchers, SSE) — see `openCanvasBoard` in canvas-operations.
|
|
2342
|
+
* Save pending boards, then select the visible owner. Existing partitions
|
|
2343
|
+
* remain live; canvas-operations starts the selected board's runtime/SSE.
|
|
1782
2344
|
*/
|
|
1783
2345
|
switchBoard(id: string | null): boolean {
|
|
1784
2346
|
if (!this._db) return false;
|
|
1785
|
-
if (this.
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
1792
|
-
let next = this.emptyPersistedState();
|
|
1793
|
-
if (id) {
|
|
1794
|
-
const state = loadStateFromDB(this._db, id);
|
|
1795
|
-
if (!state) return false;
|
|
1796
|
-
this.initializePersistedWikiLinks(state, id);
|
|
1797
|
-
next = state;
|
|
1798
|
-
}
|
|
1799
|
-
this.applyPersistedState(next);
|
|
1800
|
-
this._activeBoardId = id;
|
|
2347
|
+
if (this.boardExecution.getStore())
|
|
2348
|
+
throw new OperationError('Board-scoped operations cannot change navigation.', 409);
|
|
2349
|
+
if (id && !getBoardFromDB(this._db, id)) return false;
|
|
2350
|
+
this.flushToDisk();
|
|
2351
|
+
this.visibleBoard = id ? this.loadBoardState(id) : this.createBoardState();
|
|
2352
|
+
this.navigationGeneration = ++this.nextGeneration;
|
|
1801
2353
|
setActiveBoardIdInDB(this._db, id);
|
|
1802
2354
|
this.notifyChange('nodes');
|
|
1803
2355
|
this.notifyChange('pins');
|
|
@@ -1835,20 +2387,27 @@ class CanvasStateManager {
|
|
|
1835
2387
|
private scheduleSave(): void {
|
|
1836
2388
|
if (!this._db) return;
|
|
1837
2389
|
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
1838
|
-
this._saveTimer = setTimeout(
|
|
1839
|
-
this.
|
|
1840
|
-
|
|
1841
|
-
|
|
2390
|
+
this._saveTimer = setTimeout(
|
|
2391
|
+
this.bindBoard(() => {
|
|
2392
|
+
this._saveTimer = null;
|
|
2393
|
+
this.saveToDisk();
|
|
2394
|
+
}),
|
|
2395
|
+
SAVE_DEBOUNCE_MS,
|
|
2396
|
+
);
|
|
1842
2397
|
}
|
|
1843
2398
|
|
|
1844
2399
|
flushToDisk(): void {
|
|
1845
2400
|
if (!this._db) return;
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
2401
|
+
const current = this.boardState;
|
|
2402
|
+
for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
|
|
2403
|
+
if (board !== current && !board.saveTimer && !board.persistenceError) continue;
|
|
2404
|
+
this.boardExecution.run(board, () => {
|
|
2405
|
+
if (this._saveTimer) {
|
|
2406
|
+
clearTimeout(this._saveTimer);
|
|
2407
|
+
this._saveTimer = null;
|
|
2408
|
+
}
|
|
2409
|
+
if (!this.saveToDisk()) throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
|
|
2410
|
+
});
|
|
1852
2411
|
}
|
|
1853
2412
|
if (this._db) {
|
|
1854
2413
|
try {
|
|
@@ -1860,8 +2419,9 @@ class CanvasStateManager {
|
|
|
1860
2419
|
}
|
|
1861
2420
|
}
|
|
1862
2421
|
|
|
1863
|
-
private persistedState(): PersistedCanvasState {
|
|
1864
|
-
|
|
2422
|
+
private persistedState(externalize = true): PersistedCanvasState {
|
|
2423
|
+
const state: PersistedCanvasState = {
|
|
2424
|
+
storageVersion: this._storageVersion,
|
|
1865
2425
|
version: 1,
|
|
1866
2426
|
theme: this._theme,
|
|
1867
2427
|
tour: this._tour,
|
|
@@ -1873,7 +2433,8 @@ class CanvasStateManager {
|
|
|
1873
2433
|
contextPinMeta: this.pinMetaRecord(),
|
|
1874
2434
|
ax: this.getAxState(),
|
|
1875
2435
|
revisionState: this.revisionState(),
|
|
1876
|
-
}
|
|
2436
|
+
};
|
|
2437
|
+
return externalize ? this.externalizePersistedStateBlobs(state) : state;
|
|
1877
2438
|
}
|
|
1878
2439
|
|
|
1879
2440
|
/** Write current state to SQLite immediately. */
|
|
@@ -1887,7 +2448,7 @@ class CanvasStateManager {
|
|
|
1887
2448
|
}
|
|
1888
2449
|
const boardId = this.ensureActiveBoard();
|
|
1889
2450
|
if (!boardId) return false;
|
|
1890
|
-
saveStateToDB(this._db, boardId, this.persistedState());
|
|
2451
|
+
this._storageVersion = saveStateToDB(this._db, boardId, this.persistedState());
|
|
1891
2452
|
this._lastPersistenceError = null;
|
|
1892
2453
|
return true;
|
|
1893
2454
|
} catch (error) {
|
|
@@ -1903,18 +2464,20 @@ class CanvasStateManager {
|
|
|
1903
2464
|
}
|
|
1904
2465
|
}
|
|
1905
2466
|
|
|
1906
|
-
private _lastPersistenceError: { message: string; at: string } | null = null;
|
|
1907
|
-
|
|
1908
2467
|
/** Health view of state persistence: ok until a save fails, ok again after the next success. */
|
|
1909
2468
|
get persistenceHealth(): { ok: boolean; lastError: { message: string; at: string } | null } {
|
|
1910
|
-
|
|
2469
|
+
const lastError =
|
|
2470
|
+
[this.visibleBoard, ...this.liveBoards.values()].find((board) => board.persistenceError)?.persistenceError ??
|
|
2471
|
+
null;
|
|
2472
|
+
return { ok: lastError === null, lastError };
|
|
1911
2473
|
}
|
|
1912
2474
|
|
|
1913
2475
|
/** Close the SQLite database cleanly. Call on server shutdown. */
|
|
1914
2476
|
close(): void {
|
|
1915
2477
|
// A failed debounce has no timer left, but its edits still need saving.
|
|
1916
2478
|
// Leave the database and memory intact if the retry fails.
|
|
1917
|
-
if (this.
|
|
2479
|
+
if ([this.visibleBoard, ...this.liveBoards.values()].some((board) => board.saveTimer || board.persistenceError))
|
|
2480
|
+
this.flushToDisk();
|
|
1918
2481
|
if (this._db) {
|
|
1919
2482
|
try {
|
|
1920
2483
|
finalizeCanvasDbForClose(this._db);
|
|
@@ -1928,8 +2491,14 @@ class CanvasStateManager {
|
|
|
1928
2491
|
}
|
|
1929
2492
|
this._db = null;
|
|
1930
2493
|
}
|
|
1931
|
-
this.
|
|
1932
|
-
|
|
2494
|
+
for (const board of new Set([this.visibleBoard, ...this.liveBoards.values()])) {
|
|
2495
|
+
board.disposed = true;
|
|
2496
|
+
this.disposeBoardRuntime(board);
|
|
2497
|
+
}
|
|
2498
|
+
this.liveBoards.clear();
|
|
2499
|
+
this.hostState.capability = null;
|
|
2500
|
+
this.visibleBoard = this.createBoardState();
|
|
2501
|
+
this.navigationGeneration = ++this.nextGeneration;
|
|
1933
2502
|
}
|
|
1934
2503
|
|
|
1935
2504
|
// ── Snapshots ───────────────────────────────────────────────
|
|
@@ -1939,12 +2508,17 @@ class CanvasStateManager {
|
|
|
1939
2508
|
return join(this._workspaceRoot, PMX_CANVAS_DIR, SNAPSHOTS_SUBDIR);
|
|
1940
2509
|
}
|
|
1941
2510
|
|
|
1942
|
-
private applyPersistedState(state: PersistedCanvasState): void {
|
|
2511
|
+
private applyPersistedState(state: PersistedCanvasState, replaced = true): void {
|
|
1943
2512
|
// Validate the complete replacement before discarding any live board state.
|
|
2513
|
+
// Keep the cached task fields as the revision comparison baseline. Display
|
|
2514
|
+
// reads project fresh fields; explicit reconciliation stamps and saves them.
|
|
1944
2515
|
const nodes = (Array.isArray(state.nodes) ? state.nodes : [])
|
|
1945
2516
|
.filter((node) => node?.id)
|
|
1946
|
-
.map((node) => structuredClone(this.normalizeNode(node)));
|
|
1947
|
-
|
|
2517
|
+
.map((node) => structuredClone(this.normalizeNode(node, undefined, { projectWork: false })));
|
|
2518
|
+
if (replaced) {
|
|
2519
|
+
this._boardGeneration = ++this.nextGeneration;
|
|
2520
|
+
if (this.boardState === this.visibleBoard) this.navigationGeneration = ++this.nextGeneration;
|
|
2521
|
+
}
|
|
1948
2522
|
this._tour = state.tour ? structuredClone(state.tour) : undefined;
|
|
1949
2523
|
this.nodes.clear();
|
|
1950
2524
|
this.edges.clear();
|
|
@@ -1952,6 +2526,7 @@ class CanvasStateManager {
|
|
|
1952
2526
|
this._contextPinnedNodeIds.clear();
|
|
1953
2527
|
this._contextPinMeta.clear();
|
|
1954
2528
|
this.ax.resetCanvasBound();
|
|
2529
|
+
this._storageVersion = state.storageVersion ?? 0;
|
|
1955
2530
|
this._contentRevision = state.revisionState?.revision ?? 0;
|
|
1956
2531
|
this._revisionFloor = state.revisionState?.floor ?? 0;
|
|
1957
2532
|
this._deletionTombstones = structuredClone(state.revisionState?.tombstones ?? []);
|
|
@@ -2078,6 +2653,7 @@ class CanvasStateManager {
|
|
|
2078
2653
|
|
|
2079
2654
|
try {
|
|
2080
2655
|
const payload = this.externalizePersistedStateBlobs({
|
|
2656
|
+
storageVersion: this._storageVersion,
|
|
2081
2657
|
version: 1,
|
|
2082
2658
|
theme: this._theme,
|
|
2083
2659
|
viewport: this._viewport,
|
|
@@ -2173,6 +2749,7 @@ class CanvasStateManager {
|
|
|
2173
2749
|
restoreSnapshot(idOrName: string): boolean {
|
|
2174
2750
|
const resolved = this.readResolvedSnapshot(idOrName);
|
|
2175
2751
|
if (!resolved || resolved.state.version !== 1) return false;
|
|
2752
|
+
this.assertSnapshotRestorable(idOrName);
|
|
2176
2753
|
|
|
2177
2754
|
const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
|
|
2178
2755
|
version: 1,
|
|
@@ -2213,7 +2790,11 @@ class CanvasStateManager {
|
|
|
2213
2790
|
|
|
2214
2791
|
try {
|
|
2215
2792
|
const revisionBeforeRestore = this._contentRevision;
|
|
2216
|
-
this.applyPersistedState({
|
|
2793
|
+
this.applyPersistedState({
|
|
2794
|
+
...nextState,
|
|
2795
|
+
storageVersion: this._storageVersion,
|
|
2796
|
+
revisionState: this.revisionState(),
|
|
2797
|
+
});
|
|
2217
2798
|
this.restampRestoredContent(previousState.nodes, previousState.edges, revisionBeforeRestore);
|
|
2218
2799
|
this.scheduleSave();
|
|
2219
2800
|
this.notifyChange('nodes');
|
|
@@ -2226,7 +2807,11 @@ class CanvasStateManager {
|
|
|
2226
2807
|
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
2227
2808
|
const edgesBefore = this.getEdges();
|
|
2228
2809
|
const revisionBeforeReplay = this._contentRevision;
|
|
2229
|
-
this.applyPersistedState({
|
|
2810
|
+
this.applyPersistedState({
|
|
2811
|
+
...nextState,
|
|
2812
|
+
storageVersion: this._storageVersion,
|
|
2813
|
+
revisionState: this.revisionState(),
|
|
2814
|
+
});
|
|
2230
2815
|
this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
|
|
2231
2816
|
this.scheduleSave();
|
|
2232
2817
|
this.notifyChange('nodes');
|
|
@@ -2237,7 +2822,11 @@ class CanvasStateManager {
|
|
|
2237
2822
|
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
2238
2823
|
const edgesBefore = this.getEdges();
|
|
2239
2824
|
const revisionBeforeReplay = this._contentRevision;
|
|
2240
|
-
this.applyPersistedState({
|
|
2825
|
+
this.applyPersistedState({
|
|
2826
|
+
...previousState,
|
|
2827
|
+
storageVersion: this._storageVersion,
|
|
2828
|
+
revisionState: this.revisionState(),
|
|
2829
|
+
});
|
|
2241
2830
|
this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
|
|
2242
2831
|
this.scheduleSave();
|
|
2243
2832
|
this.notifyChange('nodes');
|
|
@@ -2256,6 +2845,19 @@ class CanvasStateManager {
|
|
|
2256
2845
|
}
|
|
2257
2846
|
}
|
|
2258
2847
|
|
|
2848
|
+
/** Old snapshots stay readable, but neither whole-board nor one-card restore crosses a move. */
|
|
2849
|
+
assertSnapshotRestorable(idOrName: string): void {
|
|
2850
|
+
if (!this._db || !this._activeBoardId) return;
|
|
2851
|
+
const resolved = this.readResolvedSnapshot(idOrName);
|
|
2852
|
+
if (!resolved) throw new OperationError('Snapshot not found.', 404);
|
|
2853
|
+
assertBoardStorageVersion(this._db, this._activeBoardId, this._storageVersion);
|
|
2854
|
+
if ((resolved.state.moveBoundary ?? null) !== boardMoveBoundary(this._db, this._activeBoardId))
|
|
2855
|
+
throw new OperationError(
|
|
2856
|
+
'This snapshot predates a move. Inspect it and reapply desired content to the current card, or restore a whole-library backup.',
|
|
2857
|
+
409,
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2259
2861
|
/** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
|
|
2260
2862
|
getSnapshotData(idOrName: string): {
|
|
2261
2863
|
name: string;
|
|
@@ -2452,18 +3054,22 @@ class CanvasStateManager {
|
|
|
2452
3054
|
: (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
|
|
2453
3055
|
).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
|
|
2454
3056
|
}));
|
|
2455
|
-
this._db
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
const
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
3057
|
+
state.storageVersion = this._db
|
|
3058
|
+
.transaction(() => {
|
|
3059
|
+
assertBoardStorageVersion(this._db!, boardId, state.storageVersion ?? 0);
|
|
3060
|
+
for (const node of legacy) {
|
|
3061
|
+
const resolved = this.resolveNodeDataBlobs(node);
|
|
3062
|
+
const content = typeof resolved.data.content === 'string' ? resolved.data.content : '';
|
|
3063
|
+
node.data = { ...node.data, wikiLinks: resolveWikiLinks(content, [], catalog) };
|
|
3064
|
+
this._db!.run('UPDATE nodes SET data = ? WHERE board_id = ? AND id = ?', [
|
|
3065
|
+
JSON.stringify(node.data),
|
|
3066
|
+
boardId,
|
|
3067
|
+
node.id,
|
|
3068
|
+
]);
|
|
3069
|
+
}
|
|
3070
|
+
return boardStorageVersion(this._db!, boardId);
|
|
3071
|
+
})
|
|
3072
|
+
.immediate();
|
|
2467
3073
|
}
|
|
2468
3074
|
|
|
2469
3075
|
private bindWikiLinks(
|
|
@@ -2587,7 +3193,7 @@ class CanvasStateManager {
|
|
|
2587
3193
|
description: `Added ${cloned.type} node "${(cloned.data.title as string) ?? cloned.id}"`,
|
|
2588
3194
|
forward: this.suppressed(() => {
|
|
2589
3195
|
this.nodes.set(cloned.id, {
|
|
2590
|
-
...structuredClone(cloned),
|
|
3196
|
+
...this.normalizeNode(structuredClone(cloned)),
|
|
2591
3197
|
contentRevision: this.nextContentRevision(),
|
|
2592
3198
|
contentChangedAt: new Date().toISOString(),
|
|
2593
3199
|
lastEditedBy: currentActor(),
|
|
@@ -2638,6 +3244,14 @@ class CanvasStateManager {
|
|
|
2638
3244
|
updateNode(id: string, patch: Partial<CanvasNodeState>, savedWikiLinks?: WikiLink[] | null): void {
|
|
2639
3245
|
const existing = this.nodes.get(id);
|
|
2640
3246
|
if (!existing) return;
|
|
3247
|
+
if (
|
|
3248
|
+
existing.type === 'status' &&
|
|
3249
|
+
typeof existing.data.workItemId === 'string' &&
|
|
3250
|
+
((patch.type !== undefined && patch.type !== existing.type) ||
|
|
3251
|
+
(patch.data && patch.data.workItemId !== existing.data.workItemId))
|
|
3252
|
+
) {
|
|
3253
|
+
throw new OperationError('Live work card references are immutable.');
|
|
3254
|
+
}
|
|
2641
3255
|
if (patch.data) {
|
|
2642
3256
|
if (patch.data.contentOrigin !== existing.data.contentOrigin) {
|
|
2643
3257
|
throw new OperationError('Content origin is immutable after creation.');
|
|
@@ -2913,6 +3527,10 @@ class CanvasStateManager {
|
|
|
2913
3527
|
}
|
|
2914
3528
|
|
|
2915
3529
|
private invalidateRelationTarget(boardId: string, nodeId: string): void {
|
|
3530
|
+
if (boardId !== this._activeBoardId && this.liveBoards.has(boardId)) {
|
|
3531
|
+
this.withBoard(boardId, () => this.invalidateRelationTarget(boardId, nodeId));
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
2916
3534
|
if (boardId === this._activeBoardId) {
|
|
2917
3535
|
const node = this.nodes.get(nodeId);
|
|
2918
3536
|
if (node) {
|
|
@@ -3220,6 +3838,9 @@ class CanvasStateManager {
|
|
|
3220
3838
|
if (update.collapsed !== undefined && update.collapsed !== existing.collapsed) {
|
|
3221
3839
|
nextPatch.collapsed = update.collapsed;
|
|
3222
3840
|
}
|
|
3841
|
+
if (update.zIndex !== undefined && update.zIndex !== existing.zIndex) {
|
|
3842
|
+
nextPatch.zIndex = update.zIndex;
|
|
3843
|
+
}
|
|
3223
3844
|
if (Object.keys(nextPatch).length === 0) {
|
|
3224
3845
|
skipped++;
|
|
3225
3846
|
continue;
|
|
@@ -3352,6 +3973,7 @@ class CanvasStateManager {
|
|
|
3352
3973
|
*/
|
|
3353
3974
|
applyPersistedAxState(ax: unknown): void {
|
|
3354
3975
|
this.ax.applyPersistedAx(ax);
|
|
3976
|
+
this.refreshLiveWorkCards();
|
|
3355
3977
|
}
|
|
3356
3978
|
|
|
3357
3979
|
getAxFocus(): PmxAxFocusState {
|
|
@@ -3371,6 +3993,14 @@ class CanvasStateManager {
|
|
|
3371
3993
|
return this.ax.getWorkItems();
|
|
3372
3994
|
}
|
|
3373
3995
|
|
|
3996
|
+
getWorkItemsOnBoard(boardId: string): PmxAxWorkItem[] {
|
|
3997
|
+
if (boardId === this._activeBoardId) return this.getWorkItems();
|
|
3998
|
+
const live = this.liveBoards.get(boardId);
|
|
3999
|
+
if (live && (live.saveTimer || live.persistenceError))
|
|
4000
|
+
return this.boardExecution.run(live, () => live.ax.getWorkItems());
|
|
4001
|
+
return this._db ? (loadStateFromDB(this._db, boardId)?.ax?.workItems ?? []) : [];
|
|
4002
|
+
}
|
|
4003
|
+
|
|
3374
4004
|
addWorkItem(
|
|
3375
4005
|
input: {
|
|
3376
4006
|
title: string;
|
|
@@ -3416,6 +4046,7 @@ class CanvasStateManager {
|
|
|
3416
4046
|
for (const nodeId of nodeIds) {
|
|
3417
4047
|
const node = this.nodes.get(nodeId);
|
|
3418
4048
|
if (!node) continue;
|
|
4049
|
+
if (node.type === 'status' && typeof node.data.workItemId === 'string') continue;
|
|
3419
4050
|
// No-op guard: a title-/agentId-only work-item patch must not push
|
|
3420
4051
|
// history entries and SSE churn for chips that aren't changing.
|
|
3421
4052
|
if (node.data.axWorkStatus === status) continue;
|
|
@@ -3657,11 +4288,15 @@ class CanvasStateManager {
|
|
|
3657
4288
|
return this.ax.getAxSteering(q);
|
|
3658
4289
|
}
|
|
3659
4290
|
|
|
3660
|
-
getPendingSteering(
|
|
4291
|
+
getPendingSteering(
|
|
4292
|
+
options: { consumer?: string; limit?: number; addressedOnly?: boolean; offset?: number } = {},
|
|
4293
|
+
): PmxAxSteeringMessage[] {
|
|
3661
4294
|
return this.ax.getPendingSteering(options);
|
|
3662
4295
|
}
|
|
3663
4296
|
|
|
3664
|
-
getPendingSteeringForContext(
|
|
4297
|
+
getPendingSteeringForContext(
|
|
4298
|
+
options: { consumer?: string; limit?: number; addressedOnly?: boolean; offset?: number } = {},
|
|
4299
|
+
): PmxAxSteeringMessage[] {
|
|
3665
4300
|
return this.ax.getPendingSteeringForContext(options);
|
|
3666
4301
|
}
|
|
3667
4302
|
|
|
@@ -3685,6 +4320,8 @@ class CanvasStateManager {
|
|
|
3685
4320
|
this._contextReadListener?.(boardId);
|
|
3686
4321
|
return read;
|
|
3687
4322
|
} catch (error) {
|
|
4323
|
+
// Explicit receipts need a distinguishable outcome, not best-effort diagnostics.
|
|
4324
|
+
if (input.deliveryId) throw error;
|
|
3688
4325
|
logCanvasStateWarning('record context read failed', error);
|
|
3689
4326
|
return null;
|
|
3690
4327
|
}
|
|
@@ -3977,4 +4614,5 @@ class CanvasStateManager {
|
|
|
3977
4614
|
// Module-level singleton — safe because Bun is single-threaded and this
|
|
3978
4615
|
// module is imported once per process. Agent tools and the HTTP server share
|
|
3979
4616
|
// the same instance; no locking needed.
|
|
4617
|
+
export const boardCatalogEpoch = randomUUID();
|
|
3980
4618
|
export const canvasState = new CanvasStateManager();
|