pmx-canvas 0.8.2 → 0.9.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/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +601 -22
- package/CHANGELOG.md +49 -0
- package/Readme.md +131 -532
- package/dist/canvas/global.css +2724 -476
- package/dist/canvas/index.js +168 -107
- package/dist/canvas/mcp-app.html +2899 -596
- package/dist/json-render/index.js +20 -20
- package/dist/types/client/canvas/BarHint.d.ts +12 -2
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
- package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandBar.d.ts +3 -4
- package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
- package/dist/types/client/canvas/ContextChip.d.ts +3 -0
- package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
- package/dist/types/client/canvas/EmptyState.d.ts +4 -4
- package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
- package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
- package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -7
- package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
- package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
- package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
- package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
- package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
- package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
- package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
- package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
- package/dist/types/client/canvas/canvas-area.d.ts +1 -1
- package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
- package/dist/types/client/canvas/graph-model.d.ts +37 -0
- package/dist/types/client/canvas/memory-layout.d.ts +23 -0
- package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
- package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
- package/dist/types/client/icons.d.ts +0 -10
- package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
- package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
- package/dist/types/client/state/boards-store.d.ts +81 -9
- package/dist/types/client/state/canvas-store.d.ts +14 -1
- package/dist/types/client/state/context-chip-store.d.ts +22 -0
- package/dist/types/client/state/context-status-store.d.ts +35 -0
- package/dist/types/client/state/intent-bridge.d.ts +11 -8
- package/dist/types/client/state/knowledge-store.d.ts +22 -0
- package/dist/types/client/state/near-pin-store.d.ts +44 -1
- package/dist/types/client/state/presence-store.d.ts +4 -4
- package/dist/types/client/state/presentation.d.ts +28 -0
- package/dist/types/client/state/relation-store.d.ts +10 -0
- package/dist/types/client/state/session-store.d.ts +41 -2
- package/dist/types/client/state/workspace-settings.d.ts +7 -0
- package/dist/types/client/types.d.ts +13 -1
- package/dist/types/client/utils/keyboard.d.ts +2 -0
- package/dist/types/client/utils/time-ago.d.ts +4 -0
- package/dist/types/mcp/context-uri-template.d.ts +3 -1
- package/dist/types/server/agent-presence.d.ts +10 -3
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +30 -7
- package/dist/types/server/canvas-operations.d.ts +24 -2
- package/dist/types/server/canvas-provenance.d.ts +22 -2
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +93 -15
- package/dist/types/server/canvas-validation.d.ts +1 -1
- package/dist/types/server/context-brief.d.ts +94 -6
- package/dist/types/server/context-reads.d.ts +33 -7
- package/dist/types/server/edit-summary.d.ts +20 -0
- package/dist/types/server/index.d.ts +71 -10
- package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
- package/dist/types/server/lifecycle-status.d.ts +10 -0
- package/dist/types/server/memory-graph.d.ts +9 -0
- package/dist/types/server/mutation-history.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
- package/dist/types/server/operations/ops/boards.d.ts +2 -1
- package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
- package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +5 -0
- package/dist/types/server/operations/ops/query.d.ts +23 -0
- package/dist/types/server/operations/registry.d.ts +1 -0
- package/dist/types/server/placement.d.ts +7 -0
- package/dist/types/server/scope-fence.d.ts +2 -1
- package/dist/types/server/spatial-analysis.d.ts +6 -3
- package/dist/types/server/tour-control.d.ts +32 -0
- package/dist/types/server/wiki-links.d.ts +14 -0
- package/dist/types/server/workbench-auth.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +2 -0
- package/dist/types/shared/agent-presence.d.ts +15 -12
- package/dist/types/shared/auto-arrange.d.ts +1 -0
- package/dist/types/shared/boards.d.ts +4 -4
- package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
- package/dist/types/shared/knowledge-presets.d.ts +6 -0
- package/dist/types/shared/lifecycle-status.d.ts +24 -0
- package/dist/types/shared/memory-graph.d.ts +53 -0
- package/dist/types/shared/relations.d.ts +68 -0
- package/dist/types/shared/semantic-attention.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +38 -0
- package/dist/types/shared/workspace-settings.d.ts +11 -0
- package/docs/RELEASE.md +21 -3
- package/docs/adr-001-bun-only-runtime.md +7 -1
- package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
- package/docs/api-reference.md +109 -0
- package/docs/ax-host-adapter-contract.md +1 -1
- package/docs/ax-state-contract.md +5 -4
- package/docs/cli.md +153 -15
- package/docs/demos/pmx-canvas-demo.mp4 +0 -0
- package/docs/design/AgentContext.dc.html +3 -3
- package/docs/design/BoardPins.dc.html +37 -22
- package/docs/design/Categories.dc.html +208 -0
- package/docs/design/CategoryEditor.dc.html +33 -0
- package/docs/design/Chrome.dc.html +52 -9
- package/docs/design/ContextChip.dc.html +224 -0
- package/docs/design/ContextChip600.dc.html +136 -0
- package/docs/design/EmptyState.dc.html +45 -0
- package/docs/design/Flows.dc.html +23 -1
- package/docs/design/HeaderMarks.dc.html +183 -0
- package/docs/design/Home.dc.html +19 -1
- package/docs/design/HomeGraph.dc.html +86 -202
- package/docs/design/HomeMap.dc.html +23 -1
- package/docs/design/HomeTeam.dc.html +32 -14
- package/docs/design/Lifecycle.dc.html +22 -0
- package/docs/design/Lifecycle600.dc.html +18 -0
- package/docs/design/LifecycleVariants.dc.html +157 -0
- package/docs/design/LinkAuthorship.dc.html +139 -0
- package/docs/design/LinksChanged.dc.html +203 -0
- package/docs/design/LinksOptions.dc.html +137 -0
- package/docs/design/LinksOptions600.dc.html +134 -0
- package/docs/design/NearPin.dc.html +9 -10
- package/docs/design/Pane600.dc.html +4 -3
- package/docs/design/Relations.dc.html +3 -2
- package/docs/design/Restore.dc.html +152 -0
- package/docs/design/SeeChange.dc.html +267 -0
- package/docs/design/Uses.dc.html +12 -6
- package/docs/design/canvas.json +146 -19
- package/docs/design.md +449 -16
- package/docs/environment.md +0 -1
- package/docs/evals/curation-effect.md +128 -0
- package/docs/http-api.md +459 -37
- package/docs/mcp.md +146 -18
- package/docs/node-types.md +58 -3
- package/docs/plans/design-gaps-2026-10-08.md +53 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
- package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
- package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
- package/docs/product-context-vision-2026-09.md +17 -1
- package/docs/product-vision-2026-09.md +1082 -43
- package/docs/product-vision-review-2026-09.md +13 -1
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +62 -2
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +601 -22
- package/skills/pmx-canvas/SKILL.md +216 -42
- package/skills/pmx-canvas/references/full-reference.md +120 -21
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/importing-context.md +132 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
- package/skills/pmx-canvas-testing/SKILL.md +20 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +12 -0
- package/src/cli/commands/boards.ts +87 -11
- package/src/cli/commands/edges.ts +28 -0
- package/src/cli/commands/groups.ts +7 -0
- package/src/cli/commands/nodes.ts +1 -0
- package/src/cli/commands/query.ts +34 -2
- package/src/cli/commands/record.ts +199 -48
- package/src/cli/index.ts +6 -1
- package/src/client/App.tsx +177 -70
- package/src/client/canvas/AttentionHistory.tsx +0 -2
- package/src/client/canvas/BarHint.tsx +40 -2
- package/src/client/canvas/BoardCopyDialog.tsx +1 -1
- package/src/client/canvas/BoardFolderDialog.tsx +7 -7
- package/src/client/canvas/BoardLinkDialog.tsx +138 -33
- package/src/client/canvas/BoardPinButton.tsx +33 -0
- package/src/client/canvas/BoardSwitcher.tsx +26 -40
- package/src/client/canvas/CanvasNode.tsx +360 -237
- package/src/client/canvas/CanvasViewport.tsx +45 -7
- package/src/client/canvas/CommandBar.tsx +29 -23
- package/src/client/canvas/CommandPalette.tsx +5 -12
- package/src/client/canvas/ConnectionBanner.tsx +3 -3
- package/src/client/canvas/ContextChip.tsx +296 -0
- package/src/client/canvas/ContextMenu.tsx +63 -5
- package/src/client/canvas/CrossBoardReferences.tsx +60 -0
- package/src/client/canvas/EdgeLayer.tsx +339 -76
- package/src/client/canvas/EmptyState.tsx +33 -18
- package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
- package/src/client/canvas/ExternalWriters.tsx +57 -42
- package/src/client/canvas/HomeGraphView.tsx +942 -0
- package/src/client/canvas/HomeMapView.tsx +641 -0
- package/src/client/canvas/HomeReviewView.tsx +167 -0
- package/src/client/canvas/HomeView.tsx +687 -160
- package/src/client/canvas/IntentLayer.tsx +19 -12
- package/src/client/canvas/KnowledgeDialog.tsx +488 -0
- package/src/client/canvas/LifecycleChrome.tsx +133 -0
- package/src/client/canvas/LifecycleFields.tsx +91 -0
- package/src/client/canvas/LifecyclePanel.tsx +533 -0
- package/src/client/canvas/MemoryInspector.tsx +104 -0
- package/src/client/canvas/Minimap.tsx +3 -3
- package/src/client/canvas/NearPinLayer.tsx +187 -0
- package/src/client/canvas/NodeContextMark.tsx +175 -97
- package/src/client/canvas/Presentation.tsx +23 -57
- package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
- package/src/client/canvas/RelationsPanel.tsx +82 -0
- package/src/client/canvas/SelectionBar.tsx +3 -6
- package/src/client/canvas/SessionPanel.tsx +90 -50
- package/src/client/canvas/SessionReceipt.tsx +311 -39
- package/src/client/canvas/ShortcutOverlay.tsx +1 -1
- package/src/client/canvas/SnapshotPanel.tsx +27 -36
- package/src/client/canvas/ToolRail.tsx +367 -192
- package/src/client/canvas/TopBar.tsx +354 -124
- package/src/client/canvas/canvas-area.ts +10 -3
- package/src/client/canvas/graph-geometry.ts +195 -0
- package/src/client/canvas/graph-model.ts +254 -0
- package/src/client/canvas/memory-layout.ts +106 -0
- package/src/client/canvas/use-chrome-height.ts +20 -0
- package/src/client/canvas/use-focus-trap.ts +31 -5
- package/src/client/canvas/use-memory-graph.ts +58 -0
- package/src/client/canvas/use-node-drag.ts +2 -1
- package/src/client/canvas/use-pan-zoom.ts +64 -7
- package/src/client/icons.tsx +0 -63
- package/src/client/mcp-app/style.css +0 -6
- package/src/client/nodes/AxStepControls.tsx +74 -50
- package/src/client/nodes/BoardNode.tsx +5 -1
- package/src/client/nodes/ContextNode.tsx +18 -11
- package/src/client/nodes/ExtAppFrame.tsx +0 -1
- package/src/client/nodes/FileNode.tsx +46 -42
- package/src/client/nodes/HtmlNode.tsx +32 -0
- package/src/client/nodes/ImageNode.tsx +1 -1
- package/src/client/nodes/InlineFormatBar.tsx +6 -9
- package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
- package/src/client/nodes/MarkdownNode.tsx +377 -109
- package/src/client/nodes/McpAppNode.tsx +50 -20
- package/src/client/nodes/MdFormatBar.tsx +21 -18
- package/src/client/nodes/PromptNode.tsx +1 -1
- package/src/client/nodes/ResponseNode.tsx +1 -1
- package/src/client/nodes/viewer-runtime-state.ts +54 -22
- package/src/client/state/attention-bridge.ts +64 -15
- package/src/client/state/boards-store.ts +193 -39
- package/src/client/state/canvas-store.ts +99 -12
- package/src/client/state/context-chip-store.ts +70 -0
- package/src/client/state/context-status-store.ts +53 -0
- package/src/client/state/intent-bridge.ts +51 -30
- package/src/client/state/knowledge-store.ts +43 -0
- package/src/client/state/near-pin-store.ts +115 -6
- package/src/client/state/presence-store.ts +7 -10
- package/src/client/state/presentation.ts +91 -0
- package/src/client/state/relation-store.ts +81 -0
- package/src/client/state/session-store.ts +144 -18
- package/src/client/state/sse-bridge.ts +206 -29
- package/src/client/state/workspace-settings.ts +11 -0
- package/src/client/theme/global.css +2724 -476
- package/src/client/types.ts +12 -0
- package/src/client/utils/keyboard.ts +10 -0
- package/src/client/utils/time-ago.ts +14 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/mcp/canvas-app.ts +12 -2
- package/src/mcp/context-uri-template.ts +7 -4
- package/src/mcp/server.ts +63 -41
- package/src/mcp/workbench-app.ts +39 -6
- package/src/server/agent-presence.ts +128 -28
- package/src/server/ax-context.ts +27 -13
- package/src/server/board-export.ts +12 -1
- package/src/server/canvas-db.ts +331 -42
- package/src/server/canvas-operations.ts +195 -83
- package/src/server/canvas-provenance.ts +53 -22
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +950 -122
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/context-brief.ts +345 -38
- package/src/server/context-reads.ts +178 -30
- package/src/server/demo-state.json +355 -24
- package/src/server/edit-summary.ts +74 -0
- package/src/server/html-surface.ts +8 -1
- package/src/server/index.ts +181 -11
- package/src/server/knowledge-lifecycle.ts +304 -0
- package/src/server/lifecycle-status.ts +232 -0
- package/src/server/memory-graph.ts +166 -0
- package/src/server/mutation-history.ts +18 -6
- package/src/server/operations/composites.ts +32 -11
- package/src/server/operations/http.ts +7 -2
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/mcp.ts +26 -1
- package/src/server/operations/ops/ax-presence.ts +1 -1
- package/src/server/operations/ops/ax-read.ts +57 -6
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/backup.ts +5 -3
- package/src/server/operations/ops/batch.ts +35 -4
- package/src/server/operations/ops/boards.ts +188 -47
- package/src/server/operations/ops/canvas-wire.ts +31 -1
- package/src/server/operations/ops/edges.ts +36 -2
- package/src/server/operations/ops/groups.ts +6 -1
- package/src/server/operations/ops/lifecycle.ts +262 -0
- package/src/server/operations/ops/memory-graph.ts +45 -0
- package/src/server/operations/ops/nodes.ts +107 -24
- package/src/server/operations/ops/query.ts +281 -68
- package/src/server/operations/ops/snapshots.ts +73 -1
- package/src/server/operations/ops/validate.ts +5 -1
- package/src/server/operations/ops/viewport.ts +97 -3
- package/src/server/operations/registry.ts +74 -25
- package/src/server/placement.ts +29 -0
- package/src/server/scope-fence.ts +78 -6
- package/src/server/server.ts +115 -16
- package/src/server/spatial-analysis.ts +48 -23
- package/src/server/tour-control.ts +82 -0
- package/src/server/wiki-links.ts +137 -0
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/agent-context.ts +3 -0
- package/src/shared/agent-presence.ts +15 -15
- package/src/shared/auto-arrange.ts +2 -0
- package/src/shared/boards.ts +4 -4
- package/src/shared/knowledge-lifecycle.ts +22 -0
- package/src/shared/knowledge-presets.ts +80 -0
- package/src/shared/lifecycle-status.ts +26 -0
- package/src/shared/memory-graph.ts +74 -0
- package/src/shared/relations.ts +98 -0
- package/src/shared/semantic-attention.ts +15 -1
- package/src/shared/tour.ts +23 -5
- package/src/shared/workspace-settings.ts +13 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
- package/dist/types/client/canvas/kind-colors.d.ts +0 -3
- package/dist/types/server/agent-context.d.ts +0 -22
- package/src/client/canvas/ContextPinBar.tsx +0 -25
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { createHash, randomUUID } from 'node:crypto';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_WORKSPACE_SETTINGS,
|
|
18
|
+
workspaceSettingsPatchSchema,
|
|
19
|
+
workspaceSettingsSchema,
|
|
20
|
+
type WorkspaceSettings,
|
|
21
|
+
} from '../shared/workspace-settings.js';
|
|
22
|
+
import { KnowledgeLifecycle, lifecycleSchema, type Lifecycle } from './knowledge-lifecycle.js';
|
|
23
|
+
import { relationSourceEnd } from '../shared/relations.js';
|
|
24
|
+
import { OperationError } from './operations/types.js';
|
|
25
|
+
import { humanPresence } from './human-presence.js';
|
|
16
26
|
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
17
27
|
import {
|
|
18
28
|
copyFileSync,
|
|
@@ -27,6 +37,8 @@ import {
|
|
|
27
37
|
import { basename, isAbsolute, join, dirname, relative } from 'node:path';
|
|
28
38
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
29
39
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
40
|
+
import { resolveWikiLinks, wikiReferences, type WikiBoard } from './wiki-links.js';
|
|
41
|
+
import { wikiLinksFromData, type WikiLink } from '../shared/memory-graph.js';
|
|
30
42
|
import {
|
|
31
43
|
openCanvasDb,
|
|
32
44
|
saveStateToDB,
|
|
@@ -41,6 +53,8 @@ import {
|
|
|
41
53
|
checkpointCanvasDb,
|
|
42
54
|
createBoardInDB,
|
|
43
55
|
createBoardWithStateInDB,
|
|
56
|
+
invalidateLinksInDB,
|
|
57
|
+
invalidateIncomingLinksInDB,
|
|
44
58
|
readContextBriefCursor,
|
|
45
59
|
advanceContextBriefCursor,
|
|
46
60
|
deleteBoardFromDB,
|
|
@@ -50,6 +64,7 @@ import {
|
|
|
50
64
|
readMetaFromDB,
|
|
51
65
|
readThemeFromDB,
|
|
52
66
|
updateBoardInDB,
|
|
67
|
+
setBoardPinInDB,
|
|
53
68
|
writeMetaToDB,
|
|
54
69
|
saveThemeToDB,
|
|
55
70
|
setActiveBoardIdInDB,
|
|
@@ -78,9 +93,8 @@ import { normalizeCanvasTheme } from './canvas-db.js';
|
|
|
78
93
|
import {
|
|
79
94
|
type CanvasPlacementRect,
|
|
80
95
|
computeGroupBounds,
|
|
96
|
+
computeManualGroupLayout,
|
|
81
97
|
computePackedGroupLayout,
|
|
82
|
-
GROUP_PAD,
|
|
83
|
-
GROUP_TITLEBAR_HEIGHT,
|
|
84
98
|
resolveGroupCollision,
|
|
85
99
|
} from './placement.js';
|
|
86
100
|
import {
|
|
@@ -117,6 +131,8 @@ import {
|
|
|
117
131
|
appendContextReadToDB,
|
|
118
132
|
loadContextReadsFromDB,
|
|
119
133
|
loadNodeReadStatusFromDB,
|
|
134
|
+
loadSeenLinksFromDB,
|
|
135
|
+
loadBoardLastReadFromDB,
|
|
120
136
|
loadReadNodeIdsSince,
|
|
121
137
|
type ContextRead,
|
|
122
138
|
type ContextReadConsumerSummary,
|
|
@@ -243,6 +259,12 @@ export interface CanvasNodeState {
|
|
|
243
259
|
lastEditedBy?: ActorAttribution;
|
|
244
260
|
/** Board-monotonic revision of this node's latest semantic content. */
|
|
245
261
|
contentRevision?: number;
|
|
262
|
+
/** Time of that semantic change; absent for content created before this clock was recorded. */
|
|
263
|
+
contentChangedAt?: string;
|
|
264
|
+
/** Initial authored freshness, not evidence validation. Imports and undated existing content have none. */
|
|
265
|
+
freshnessStartedAt?: string;
|
|
266
|
+
/** Board-monotonic revision of this node's latest link change, kept apart from its text. */
|
|
267
|
+
linksRevision?: number;
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
export interface NodeDeletionTombstone {
|
|
@@ -274,10 +296,41 @@ export interface CanvasEdge {
|
|
|
274
296
|
id: string;
|
|
275
297
|
from: string;
|
|
276
298
|
to: string;
|
|
299
|
+
/** Target on another board. Source and mutation ownership stay on the active board. */
|
|
300
|
+
toBoardId?: string;
|
|
277
301
|
type: 'relation' | 'depends-on' | 'flow' | 'references';
|
|
302
|
+
/** Open semantic relation kind, independent of the structural edge type. */
|
|
303
|
+
kind?: string;
|
|
304
|
+
reason?: string;
|
|
305
|
+
/** Source content observed when an explicit dependency was authored. Never refreshed by reads. */
|
|
306
|
+
sourceRevision?: number;
|
|
278
307
|
label?: string;
|
|
279
308
|
style?: 'solid' | 'dashed' | 'dotted';
|
|
280
309
|
animated?: boolean;
|
|
310
|
+
/** Board revision of this link's latest add, retype or relabel (0 = never stamped). */
|
|
311
|
+
revision?: number;
|
|
312
|
+
/** Who made that change, and when: the link's author until a person changes it again. */
|
|
313
|
+
changedBy?: ActorAttribution;
|
|
314
|
+
changedAt?: string;
|
|
315
|
+
/** Who first drew the link, and when (docs/design/LinkAuthorship.dc.html). */
|
|
316
|
+
createdBy?: ActorAttribution;
|
|
317
|
+
createdAt?: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
type EdgeStamp = Pick<CanvasEdge, 'revision' | 'changedBy' | 'changedAt' | 'createdBy' | 'createdAt'>;
|
|
321
|
+
|
|
322
|
+
/** A link's authorship and revision fields, without the relation itself. */
|
|
323
|
+
function edgeStamp(edge: CanvasEdge): EdgeStamp {
|
|
324
|
+
const { revision, changedBy, changedAt, createdBy, createdAt } = edge;
|
|
325
|
+
return Object.fromEntries(
|
|
326
|
+
Object.entries({ revision, changedBy, changedAt, createdBy, createdAt }).filter(([, value]) => value !== undefined),
|
|
327
|
+
) as EdgeStamp;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The relation itself, without its authorship and revision fields. */
|
|
331
|
+
function edgeWithoutStamp(edge: CanvasEdge): CanvasEdge {
|
|
332
|
+
const { revision: _r, changedBy: _cb, changedAt: _ca, createdBy: _crb, createdAt: _cra, ...rest } = edge;
|
|
333
|
+
return rest;
|
|
281
334
|
}
|
|
282
335
|
|
|
283
336
|
export interface CanvasAnnotationPoint {
|
|
@@ -389,14 +442,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
389
442
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
390
443
|
}
|
|
391
444
|
|
|
392
|
-
function replaceById<T extends { id: string }>(list: T[], item: T): T[] {
|
|
393
|
-
const idx = list.findIndex((x) => x.id === item.id);
|
|
394
|
-
if (idx === -1) return [...list, item];
|
|
395
|
-
const copy = list.slice();
|
|
396
|
-
copy[idx] = item;
|
|
397
|
-
return copy;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
445
|
function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
|
|
401
446
|
return (
|
|
402
447
|
isRecord(value) &&
|
|
@@ -597,6 +642,16 @@ class CanvasStateManager {
|
|
|
597
642
|
|
|
598
643
|
private normalizeNode(node: CanvasNodeState): CanvasNodeState {
|
|
599
644
|
const data = { ...node.data };
|
|
645
|
+
// Lifecycle is optional metadata, never a card taxonomy or validation clock.
|
|
646
|
+
delete data.categoryId;
|
|
647
|
+
if (data.contentOrigin !== undefined && data.contentOrigin !== 'authored' && data.contentOrigin !== 'imported') {
|
|
648
|
+
throw new OperationError('Content origin must be authored or imported.');
|
|
649
|
+
}
|
|
650
|
+
if (data.lifecycleOverride !== undefined && data.lifecycleOverride !== null) {
|
|
651
|
+
const lifecycle = lifecycleSchema.safeParse(data.lifecycleOverride);
|
|
652
|
+
if (!lifecycle.success) throw new OperationError('Invalid lifecycle override.');
|
|
653
|
+
data.lifecycleOverride = lifecycle.data;
|
|
654
|
+
}
|
|
600
655
|
delete data.createdBy;
|
|
601
656
|
delete data.lastEditedBy;
|
|
602
657
|
delete data.contentRevision;
|
|
@@ -606,6 +661,7 @@ class CanvasStateManager {
|
|
|
606
661
|
createdBy: node.createdBy ?? unknownActor(),
|
|
607
662
|
lastEditedBy: node.lastEditedBy ?? unknownActor(),
|
|
608
663
|
contentRevision: Number.isSafeInteger(node.contentRevision) ? node.contentRevision : 0,
|
|
664
|
+
linksRevision: Number.isSafeInteger(node.linksRevision) ? node.linksRevision : 0,
|
|
609
665
|
};
|
|
610
666
|
}
|
|
611
667
|
|
|
@@ -636,7 +692,7 @@ class CanvasStateManager {
|
|
|
636
692
|
retentionFloor: this._revisionFloor,
|
|
637
693
|
reset,
|
|
638
694
|
nodes: Array.from(this.nodes.values())
|
|
639
|
-
.filter((node) => reset || (node.contentRevision ?? 0) > cursor)
|
|
695
|
+
.filter((node) => reset || Math.max(node.contentRevision ?? 0, node.linksRevision ?? 0) > cursor)
|
|
640
696
|
.map((node) => structuredClone(this.nodeForRead(node))),
|
|
641
697
|
deleted: reset
|
|
642
698
|
? []
|
|
@@ -724,38 +780,8 @@ class CanvasStateManager {
|
|
|
724
780
|
const snapshot = this.getGroupSnapshot(groupId);
|
|
725
781
|
if (!snapshot || snapshot.children.length === 0) return;
|
|
726
782
|
if (snapshot.group.data.frameMode === 'manual') {
|
|
727
|
-
const
|
|
728
|
-
const
|
|
729
|
-
const top = snapshot.group.position.y + GROUP_TITLEBAR_HEIGHT + GROUP_PAD;
|
|
730
|
-
const right = snapshot.group.position.x + snapshot.group.size.width - GROUP_PAD;
|
|
731
|
-
const gap = 24;
|
|
732
|
-
let cursorX = left;
|
|
733
|
-
let cursorY = top;
|
|
734
|
-
let rowHeight = 0;
|
|
735
|
-
|
|
736
|
-
for (const child of sorted) {
|
|
737
|
-
if (layout === 'column') {
|
|
738
|
-
this.nodes.set(child.id, { ...child, position: { x: left, y: cursorY } });
|
|
739
|
-
cursorY += child.size.height + gap;
|
|
740
|
-
continue;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (layout === 'flow') {
|
|
744
|
-
this.nodes.set(child.id, { ...child, position: { x: cursorX, y: top } });
|
|
745
|
-
cursorX += child.size.width + gap;
|
|
746
|
-
continue;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (cursorX > left && cursorX + child.size.width > right) {
|
|
750
|
-
cursorX = left;
|
|
751
|
-
cursorY += rowHeight + gap;
|
|
752
|
-
rowHeight = 0;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
this.nodes.set(child.id, { ...child, position: { x: cursorX, y: cursorY } });
|
|
756
|
-
cursorX += child.size.width + gap;
|
|
757
|
-
rowHeight = Math.max(rowHeight, child.size.height);
|
|
758
|
-
}
|
|
783
|
+
const positions = computeManualGroupLayout(snapshot.children, snapshot.group, layout);
|
|
784
|
+
for (const child of snapshot.children) this.nodes.set(child.id, { ...child, position: positions.get(child.id)! });
|
|
759
785
|
return;
|
|
760
786
|
}
|
|
761
787
|
|
|
@@ -783,6 +809,16 @@ class CanvasStateManager {
|
|
|
783
809
|
private _db: import('bun:sqlite').Database | null = null;
|
|
784
810
|
private _dbPath: string | null = null;
|
|
785
811
|
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
812
|
+
readonly knowledge = new KnowledgeLifecycle(
|
|
813
|
+
() => this._db,
|
|
814
|
+
() => this.notifyChange('nodes'),
|
|
815
|
+
(boardId, nodeId) => {
|
|
816
|
+
const node = this.nodes.get(nodeId);
|
|
817
|
+
if (boardId !== this._activeBoardId || !node) throw new OperationError('Card not found.', 404);
|
|
818
|
+
return node.contentRevision ?? 0;
|
|
819
|
+
},
|
|
820
|
+
() => this.flushToDisk(),
|
|
821
|
+
);
|
|
786
822
|
|
|
787
823
|
/** Workspace root backing persistence and workspace-relative path resolution. */
|
|
788
824
|
get workspaceRoot(): string {
|
|
@@ -792,6 +828,8 @@ class CanvasStateManager {
|
|
|
792
828
|
/** Set the workspace root to enable auto-persistence. */
|
|
793
829
|
setWorkspaceRoot(workspaceRoot: string): void {
|
|
794
830
|
this.close();
|
|
831
|
+
// Detached content cannot belong to the next database, even if loading it fails.
|
|
832
|
+
this.applyPersistedState(this.emptyPersistedState());
|
|
795
833
|
this._workspaceRoot = workspaceRoot;
|
|
796
834
|
|
|
797
835
|
// Determine DB path. PMX_CANVAS_DB_PATH wins; PMX_CANVAS_STATE_FILE is
|
|
@@ -1002,6 +1040,7 @@ class CanvasStateManager {
|
|
|
1002
1040
|
const boardId = getActiveBoardIdFromDB(this._db);
|
|
1003
1041
|
const state = boardId ? loadStateFromDB(this._db, boardId) : null;
|
|
1004
1042
|
if (boardId && state) {
|
|
1043
|
+
this.initializePersistedWikiLinks(state, boardId);
|
|
1005
1044
|
this.applyPersistedState(state);
|
|
1006
1045
|
this._activeBoardId = boardId;
|
|
1007
1046
|
return true;
|
|
@@ -1009,6 +1048,8 @@ class CanvasStateManager {
|
|
|
1009
1048
|
this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
|
|
1010
1049
|
} catch (error) {
|
|
1011
1050
|
logCanvasStateWarning('load state from sqlite failed', error, {});
|
|
1051
|
+
// Failed replacement is not an empty board: retain any live state and identity.
|
|
1052
|
+
return false;
|
|
1012
1053
|
}
|
|
1013
1054
|
}
|
|
1014
1055
|
|
|
@@ -1107,10 +1148,305 @@ class CanvasStateManager {
|
|
|
1107
1148
|
return board ? { ...board, nodeCount: this.nodes.size } : null;
|
|
1108
1149
|
}
|
|
1109
1150
|
|
|
1110
|
-
/**
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1151
|
+
/** Synchronous, owner-controlled static creation. No board activation or runtime hydration. */
|
|
1152
|
+
createStoredNode(
|
|
1153
|
+
boardId: string,
|
|
1154
|
+
requestId: string | undefined,
|
|
1155
|
+
fingerprint: string,
|
|
1156
|
+
build: (nodes: CanvasNodeState[]) => CanvasNodeState,
|
|
1157
|
+
): CanvasNodeState {
|
|
1158
|
+
return this.createStoredCreation(boardId, requestId, fingerprint, (state, invalidate) => {
|
|
1159
|
+
const next = this.stampStoredNode(this.normalizeNode(build(state.nodes)), state);
|
|
1160
|
+
if (next.type === 'markdown') {
|
|
1161
|
+
const catalog = listBoardsFromDB(this._db!).map((board) => ({
|
|
1162
|
+
id: board.id,
|
|
1163
|
+
name: board.name,
|
|
1164
|
+
cards: (board.id === boardId
|
|
1165
|
+
? [...state.nodes, next]
|
|
1166
|
+
: (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
|
|
1167
|
+
).map((card) => ({ id: card.id, title: typeof card.data.title === 'string' ? card.data.title : card.id })),
|
|
1168
|
+
}));
|
|
1169
|
+
next.data.wikiLinks = resolveWikiLinks(String(next.data.content ?? ''), [], catalog);
|
|
1170
|
+
}
|
|
1171
|
+
state.nodes.push(next);
|
|
1172
|
+
const links = next.type === 'markdown' ? wikiLinksFromData(next.data) : [];
|
|
1173
|
+
if (links.length) {
|
|
1174
|
+
invalidate(boardId, next.id);
|
|
1175
|
+
const targets = new Map(
|
|
1176
|
+
links.flatMap(({ target }) => (target?.cardId ? [[JSON.stringify(target), target] as const] : [])),
|
|
1177
|
+
);
|
|
1178
|
+
for (const target of targets.values()) invalidate(target.boardId, target.cardId!);
|
|
1179
|
+
}
|
|
1180
|
+
return next;
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private stampStoredNode(node: CanvasNodeState, state: PersistedCanvasState): CanvasNodeState {
|
|
1185
|
+
const now = new Date().toISOString();
|
|
1186
|
+
const provenance = node.data.provenance as { sourceKind?: string } | undefined;
|
|
1187
|
+
const imported =
|
|
1188
|
+
node.data.contentOrigin === 'imported' ||
|
|
1189
|
+
node.data.source !== undefined ||
|
|
1190
|
+
(provenance !== undefined && provenance.sourceKind !== 'recipe');
|
|
1191
|
+
return {
|
|
1192
|
+
...node,
|
|
1193
|
+
createdBy: currentActor(),
|
|
1194
|
+
lastEditedBy: currentActor(),
|
|
1195
|
+
contentRevision: ++state.revisionState!.revision,
|
|
1196
|
+
contentChangedAt: now,
|
|
1197
|
+
freshnessStartedAt: imported ? undefined : now,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
createStoredGroup(
|
|
1202
|
+
boardId: string,
|
|
1203
|
+
requestId: string | undefined,
|
|
1204
|
+
fingerprint: string,
|
|
1205
|
+
build: (nodes: CanvasNodeState[]) => CanvasNodeState,
|
|
1206
|
+
childLayout?: 'grid' | 'column' | 'flow',
|
|
1207
|
+
): CanvasNodeState {
|
|
1208
|
+
return this.createStoredCreation(boardId, requestId, fingerprint, (state) => {
|
|
1209
|
+
const group = this.stampStoredNode(this.normalizeNode(build(state.nodes)), state);
|
|
1210
|
+
const ids = group.data.children as string[];
|
|
1211
|
+
const children = ids.map((id) => state.nodes.find((node) => node.id === id)!);
|
|
1212
|
+
const affected = new Set(ids);
|
|
1213
|
+
// A group can itself be a member; moving its frame moves its descendants too.
|
|
1214
|
+
const move = (node: CanvasNodeState, position: { x: number; y: number }) => {
|
|
1215
|
+
const dx = position.x - node.position.x,
|
|
1216
|
+
dy = position.y - node.position.y;
|
|
1217
|
+
const descendants = new Set<string>([node.id]);
|
|
1218
|
+
for (const id of descendants) {
|
|
1219
|
+
const child = state.nodes.find((item) => item.id === id)!;
|
|
1220
|
+
affected.add(id);
|
|
1221
|
+
if (child.type === 'group') for (const nested of child.data.children as string[]) descendants.add(nested);
|
|
1222
|
+
child.position = { x: child.position.x + dx, y: child.position.y + dy };
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
if (childLayout) {
|
|
1226
|
+
if (group.data.frameMode === 'manual') {
|
|
1227
|
+
const positions = computeManualGroupLayout(children, group, childLayout);
|
|
1228
|
+
for (const child of children) move(child, positions.get(child.id)!);
|
|
1229
|
+
} else {
|
|
1230
|
+
const packed = computePackedGroupLayout(children);
|
|
1231
|
+
for (const child of children) move(child, packed.positions.get(child.id)!);
|
|
1232
|
+
if (packed.bounds) {
|
|
1233
|
+
group.position = { x: packed.bounds.x, y: packed.bounds.y };
|
|
1234
|
+
group.size = { width: packed.bounds.width, height: packed.bounds.height };
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
// Detach from old parents before attaching. Refit only affected ancestors.
|
|
1239
|
+
const parents = new Set<string>();
|
|
1240
|
+
for (const child of children) {
|
|
1241
|
+
if (typeof child.data.parentGroup === 'string') parents.add(child.data.parentGroup);
|
|
1242
|
+
child.data = { ...child.data, parentGroup: group.id };
|
|
1243
|
+
}
|
|
1244
|
+
for (const parentId of parents) {
|
|
1245
|
+
const parent = state.nodes.find((node) => node.id === parentId)!;
|
|
1246
|
+
affected.add(parentId);
|
|
1247
|
+
parent.data = {
|
|
1248
|
+
...parent.data,
|
|
1249
|
+
children: (parent.data.children as string[]).filter((id) => !ids.includes(id)),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
const refit = (parentId: string) => {
|
|
1253
|
+
const parent = state.nodes.find((node) => node.id === parentId)!;
|
|
1254
|
+
if (parent.data.frameMode === 'manual') return;
|
|
1255
|
+
affected.add(parentId);
|
|
1256
|
+
const bounds = computeGroupBounds(
|
|
1257
|
+
state.nodes.filter((node) => (parent.data.children as string[]).includes(node.id)),
|
|
1258
|
+
);
|
|
1259
|
+
if (bounds) {
|
|
1260
|
+
parent.position = { x: bounds.x, y: bounds.y };
|
|
1261
|
+
parent.size = { width: bounds.width, height: bounds.height };
|
|
1262
|
+
}
|
|
1263
|
+
if (typeof parent.data.parentGroup === 'string') {
|
|
1264
|
+
refit(parent.data.parentGroup);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
for (const parentId of parents) refit(parentId);
|
|
1268
|
+
for (const node of state.nodes.filter((item) => affected.has(item.id))) {
|
|
1269
|
+
const held = humanPresence.lockedNodes().get(node.id);
|
|
1270
|
+
if (held) throw new OperationError(`Node "${node.id}" is being edited by ${held} right now.`, 409);
|
|
1271
|
+
// Group membership is semantic; geometry alone does not renew freshness.
|
|
1272
|
+
if (ids.includes(node.id) || parents.has(node.id)) {
|
|
1273
|
+
delete node.data.agentSummary;
|
|
1274
|
+
delete node.data.summary;
|
|
1275
|
+
node.contentRevision = ++state.revisionState!.revision;
|
|
1276
|
+
node.lastEditedBy = currentActor();
|
|
1277
|
+
node.contentChangedAt = new Date().toISOString();
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
state.nodes.push(group);
|
|
1281
|
+
return group;
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
createStoredEdge(
|
|
1286
|
+
boardId: string,
|
|
1287
|
+
requestId: string | undefined,
|
|
1288
|
+
fingerprint: string,
|
|
1289
|
+
build: (nodes: CanvasNodeState[]) => CanvasEdge,
|
|
1290
|
+
): CanvasEdge {
|
|
1291
|
+
return this.createStoredCreation(boardId, requestId, fingerprint, (state, invalidate) => {
|
|
1292
|
+
const edge = build(state.nodes);
|
|
1293
|
+
if (
|
|
1294
|
+
(!edge.toBoardId && edge.from === edge.to) ||
|
|
1295
|
+
state.edges.some(
|
|
1296
|
+
(item) =>
|
|
1297
|
+
item.from === edge.from &&
|
|
1298
|
+
item.to === edge.to &&
|
|
1299
|
+
item.toBoardId === edge.toBoardId &&
|
|
1300
|
+
item.type === edge.type &&
|
|
1301
|
+
item.kind === edge.kind,
|
|
1302
|
+
)
|
|
1303
|
+
)
|
|
1304
|
+
throw new OperationError('Duplicate or self-edge.');
|
|
1305
|
+
const targetBoard = edge.toBoardId ?? boardId;
|
|
1306
|
+
const target =
|
|
1307
|
+
targetBoard === boardId
|
|
1308
|
+
? state.nodes.find((node) => node.id === edge.to)
|
|
1309
|
+
: this.readBoard(targetBoard)?.layout.nodes.find((node) => node.id === edge.to);
|
|
1310
|
+
for (const id of [edge.from, edge.to]) {
|
|
1311
|
+
const held = humanPresence.lockedNodes().get(id);
|
|
1312
|
+
if (held) throw new OperationError(`Node "${id}" is being edited by ${held} right now.`, 409);
|
|
1313
|
+
}
|
|
1314
|
+
const sourceEnd = relationSourceEnd(edge);
|
|
1315
|
+
const source = sourceEnd === 'to' ? target : state.nodes.find((node) => node.id === edge.from);
|
|
1316
|
+
const stamped = {
|
|
1317
|
+
...edge,
|
|
1318
|
+
sourceRevision: sourceEnd ? source?.contentRevision : undefined,
|
|
1319
|
+
revision: ++state.revisionState!.revision,
|
|
1320
|
+
createdBy: currentActor(),
|
|
1321
|
+
changedBy: currentActor(),
|
|
1322
|
+
createdAt: new Date().toISOString(),
|
|
1323
|
+
changedAt: new Date().toISOString(),
|
|
1324
|
+
};
|
|
1325
|
+
state.edges.push(stamped);
|
|
1326
|
+
invalidate(boardId, edge.from);
|
|
1327
|
+
invalidate(targetBoard, edge.to);
|
|
1328
|
+
return stamped;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Stored creations share one durable receipt/commit boundary, never a temporary board switch. */
|
|
1333
|
+
private createStoredCreation<T extends CanvasNodeState | CanvasEdge>(
|
|
1334
|
+
boardId: string,
|
|
1335
|
+
requestId: string | undefined,
|
|
1336
|
+
fingerprint: string,
|
|
1337
|
+
build: (state: PersistedCanvasState, invalidate: (boardId: string, nodeId: string) => void) => T,
|
|
1338
|
+
): T {
|
|
1339
|
+
const db = this._db;
|
|
1340
|
+
if (!db) throw new OperationError('Destination/retry creation requires persistent workspace storage.');
|
|
1341
|
+
const active = boardId === this._activeBoardId;
|
|
1342
|
+
let committed: PersistedCanvasState | undefined;
|
|
1343
|
+
let before: PersistedCanvasState | undefined;
|
|
1344
|
+
const activeLinkRevisions = new Map<string, number>();
|
|
1345
|
+
let activeRevision = this._contentRevision;
|
|
1346
|
+
const result = db.transaction(() => {
|
|
1347
|
+
const previous = requestId
|
|
1348
|
+
? db
|
|
1349
|
+
.query<{ board_id: string; fingerprint: string; node: string }, [string]>(
|
|
1350
|
+
'SELECT board_id, fingerprint, node FROM node_creation_requests WHERE request_id = ?',
|
|
1351
|
+
)
|
|
1352
|
+
.get(requestId)
|
|
1353
|
+
: null;
|
|
1354
|
+
if (previous) {
|
|
1355
|
+
if (previous.board_id !== boardId || previous.fingerprint !== fingerprint)
|
|
1356
|
+
throw new OperationError('requestId was already used with different creation input or destination.', 409);
|
|
1357
|
+
// Return the original receipt even if the node has since been edited or removed.
|
|
1358
|
+
return JSON.parse(previous.node) as T;
|
|
1359
|
+
}
|
|
1360
|
+
// Include pending edits in catalog resolution and durable relation clocks,
|
|
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);
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
for (const [targetBoardId, nodeId] of invalidations) invalidate(targetBoardId, nodeId);
|
|
1388
|
+
if ('data' in next) invalidateIncomingLinksInDB(db, boardId, next.id, invalidate);
|
|
1389
|
+
committed = loadStateFromDB(db, boardId)!;
|
|
1390
|
+
next = ([...committed.nodes, ...committed.edges].find((candidate) => candidate.id === next.id) ?? next) as T;
|
|
1391
|
+
if (active) activeRevision = committed.revisionState!.revision;
|
|
1392
|
+
if (requestId)
|
|
1393
|
+
db.run('INSERT INTO node_creation_requests VALUES (?, ?, ?, ?)', [
|
|
1394
|
+
requestId,
|
|
1395
|
+
boardId,
|
|
1396
|
+
fingerprint,
|
|
1397
|
+
JSON.stringify(next),
|
|
1398
|
+
]);
|
|
1399
|
+
return next;
|
|
1400
|
+
})();
|
|
1401
|
+
if (committed) {
|
|
1402
|
+
if (active) {
|
|
1403
|
+
const changed = committed.nodes.filter(
|
|
1404
|
+
(node) => JSON.stringify(before!.nodes.find((old) => old.id === node.id)) !== JSON.stringify(node),
|
|
1405
|
+
);
|
|
1406
|
+
for (const node of changed) this.nodes.set(node.id, structuredClone(node));
|
|
1407
|
+
if ('data' in result && result.type !== 'group') this.recordNodeCreation(result);
|
|
1408
|
+
else {
|
|
1409
|
+
for (const edge of committed.edges) this.edges.set(edge.id, structuredClone(edge));
|
|
1410
|
+
const edge = 'data' in result ? undefined : (result as CanvasEdge);
|
|
1411
|
+
const restore = (nodes: CanvasNodeState[]) =>
|
|
1412
|
+
this.suppressed(() => {
|
|
1413
|
+
this.removeNode(result.id);
|
|
1414
|
+
for (const node of nodes) {
|
|
1415
|
+
const existing = this.nodes.get(node.id);
|
|
1416
|
+
if (existing) this.setSemanticNode({ ...structuredClone(node), linksRevision: existing.linksRevision });
|
|
1417
|
+
else
|
|
1418
|
+
this.nodes.set(node.id, {
|
|
1419
|
+
...structuredClone(node),
|
|
1420
|
+
contentRevision: this.nextContentRevision(),
|
|
1421
|
+
lastEditedBy: currentActor(),
|
|
1422
|
+
contentChangedAt: new Date().toISOString(),
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
this.scheduleSave();
|
|
1426
|
+
this.notifyChange('nodes');
|
|
1427
|
+
});
|
|
1428
|
+
this.recordMutation({
|
|
1429
|
+
operationType: 'data' in result ? 'groupNodes' : 'addEdge',
|
|
1430
|
+
description: `Created ${result.type} on stored board`,
|
|
1431
|
+
forward: edge ? this.suppressed(() => this.addEdge(structuredClone(edge))) : restore(changed),
|
|
1432
|
+
inverse:
|
|
1433
|
+
'data' in result
|
|
1434
|
+
? restore(before!.nodes.filter((node) => changed.some((item) => item.id === node.id)))
|
|
1435
|
+
: this.suppressed(() => this.removeEdge(result.id)),
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
for (const [id, revision] of activeLinkRevisions) {
|
|
1440
|
+
const existing = this.nodes.get(id);
|
|
1441
|
+
if (existing) this.nodes.set(id, { ...existing, linksRevision: revision });
|
|
1442
|
+
}
|
|
1443
|
+
this._contentRevision = activeRevision;
|
|
1444
|
+
if (active || activeLinkRevisions.size) this.notifyChange('nodes');
|
|
1445
|
+
}
|
|
1446
|
+
return result;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/** Read a board without activating it. This projection is not a write payload. */
|
|
1114
1450
|
readBoard(
|
|
1115
1451
|
id: string,
|
|
1116
1452
|
includeBlobs = true,
|
|
@@ -1145,6 +1481,7 @@ class CanvasStateManager {
|
|
|
1145
1481
|
}
|
|
1146
1482
|
const state = loadStateFromDB(this._db, id);
|
|
1147
1483
|
if (!state) return null;
|
|
1484
|
+
this.initializePersistedWikiLinks(state, id);
|
|
1148
1485
|
const pins = new Set(state.contextPins);
|
|
1149
1486
|
state.nodes = state.nodes.map((node) => ({
|
|
1150
1487
|
...(includeBlobs ? this.resolveNodeDataBlobs(node) : node),
|
|
@@ -1229,12 +1566,15 @@ class CanvasStateManager {
|
|
|
1229
1566
|
}
|
|
1230
1567
|
|
|
1231
1568
|
/** Creates a board without opening it. */
|
|
1232
|
-
createBoard(name: string,
|
|
1569
|
+
createBoard(name: string, folder: string | null = null): CanvasBoard | null {
|
|
1233
1570
|
if (!this._db) return null;
|
|
1234
|
-
return createBoardInDB(this._db, name,
|
|
1571
|
+
return createBoardInDB(this._db, name, folder);
|
|
1235
1572
|
}
|
|
1236
1573
|
|
|
1237
|
-
updateBoard(
|
|
1574
|
+
updateBoard(
|
|
1575
|
+
id: string,
|
|
1576
|
+
patch: Pick<Parameters<typeof updateBoardInDB>[2], 'name' | 'folder' | 'mapPosition' | 'relationVocabulary'>,
|
|
1577
|
+
): boolean {
|
|
1238
1578
|
return this._db ? updateBoardInDB(this._db, id, patch) : false;
|
|
1239
1579
|
}
|
|
1240
1580
|
|
|
@@ -1246,11 +1586,37 @@ class CanvasStateManager {
|
|
|
1246
1586
|
return updateBoardInDB(this._db, id, { readmeNodeId: nodeId });
|
|
1247
1587
|
}
|
|
1248
1588
|
|
|
1589
|
+
/**
|
|
1590
|
+
* Pin a board into the agent's working set (vision move 0a). Workspace-level:
|
|
1591
|
+
* survives board switches and restarts. A repeat pin keeps its first
|
|
1592
|
+
* attribution, like card pins. False when the board does not exist.
|
|
1593
|
+
*/
|
|
1594
|
+
pinBoard(id: string, reason?: string): boolean {
|
|
1595
|
+
if (!this._db) return false;
|
|
1596
|
+
const board = this.listBoards().find((entry) => entry.id === id);
|
|
1597
|
+
if (!board) return false;
|
|
1598
|
+
if (board.pin) return true;
|
|
1599
|
+
const pinned = setBoardPinInDB(this._db, id, {
|
|
1600
|
+
pinnedBy: currentActor(),
|
|
1601
|
+
pinnedAt: new Date().toISOString(),
|
|
1602
|
+
...(reason ? { reason } : {}),
|
|
1603
|
+
});
|
|
1604
|
+
if (pinned) this.notifyChange('pins');
|
|
1605
|
+
return pinned;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
unpinBoard(id: string): boolean {
|
|
1609
|
+
if (!this._db) return false;
|
|
1610
|
+
const unpinned = setBoardPinInDB(this._db, id, null);
|
|
1611
|
+
if (unpinned) this.notifyChange('pins');
|
|
1612
|
+
return unpinned;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1249
1615
|
/** Bounded inactive-board creation; never replaces the state the human has open. */
|
|
1250
1616
|
createBoardFromBoard(input: {
|
|
1251
1617
|
sourceBoardId: string;
|
|
1252
1618
|
name: string;
|
|
1253
|
-
|
|
1619
|
+
folder?: string | null;
|
|
1254
1620
|
nodeIds?: string[];
|
|
1255
1621
|
includeReadme?: boolean;
|
|
1256
1622
|
includeStructure?: boolean;
|
|
@@ -1314,6 +1680,8 @@ class CanvasStateManager {
|
|
|
1314
1680
|
createdBy: actor,
|
|
1315
1681
|
lastEditedBy: actor,
|
|
1316
1682
|
contentRevision: index + 1,
|
|
1683
|
+
// The copy starts its own revision history; no read on it has carried links yet.
|
|
1684
|
+
linksRevision: 0,
|
|
1317
1685
|
};
|
|
1318
1686
|
});
|
|
1319
1687
|
nodes.push({
|
|
@@ -1330,8 +1698,14 @@ class CanvasStateManager {
|
|
|
1330
1698
|
contentRevision: nodes.length + 1,
|
|
1331
1699
|
});
|
|
1332
1700
|
const edges = source.layout.edges
|
|
1333
|
-
.filter((edge) => ids.has(edge.from) && ids.has(edge.to))
|
|
1334
|
-
.map((
|
|
1701
|
+
.filter((edge) => ids.has(edge.from) && (edge.toBoardId || ids.has(edge.to)))
|
|
1702
|
+
.map((source) => edgeWithoutStamp(source))
|
|
1703
|
+
.map((edge) => ({
|
|
1704
|
+
...edge,
|
|
1705
|
+
id: `${edge.id.startsWith('codegraph-') ? 'codegraph-' : edge.id.startsWith('boardmap-') ? 'boardmap-' : ''}${randomUUID()}`,
|
|
1706
|
+
from: ids.get(edge.from)!,
|
|
1707
|
+
to: edge.toBoardId ? edge.to : ids.get(edge.to)!,
|
|
1708
|
+
}));
|
|
1335
1709
|
const state: PersistedCanvasState = {
|
|
1336
1710
|
version: 1,
|
|
1337
1711
|
theme: source.state.theme,
|
|
@@ -1346,10 +1720,17 @@ class CanvasStateManager {
|
|
|
1346
1720
|
const board = createBoardWithStateInDB(
|
|
1347
1721
|
this._db,
|
|
1348
1722
|
input.name,
|
|
1349
|
-
input.
|
|
1723
|
+
input.folder ?? null,
|
|
1350
1724
|
this.externalizePersistedStateBlobs(state),
|
|
1351
1725
|
input.includeReadme && source.board.readmeNodeId ? (ids.get(source.board.readmeNodeId) ?? null) : null,
|
|
1726
|
+
(boardId, nodeId) => this.invalidateRelationTarget(boardId, nodeId),
|
|
1352
1727
|
);
|
|
1728
|
+
updateBoardInDB(this._db, board.id, {
|
|
1729
|
+
previousBoardId: source.board.id,
|
|
1730
|
+
relationVocabulary: source.board.relationVocabulary,
|
|
1731
|
+
});
|
|
1732
|
+
board.previousBoardId = source.board.id;
|
|
1733
|
+
board.relationVocabulary = source.board.relationVocabulary;
|
|
1353
1734
|
const referencedAttachmentIds = new Set<string>();
|
|
1354
1735
|
for (const node of nodes) {
|
|
1355
1736
|
if (typeof node.data.attachmentId === 'string') referencedAttachmentIds.add(node.data.attachmentId);
|
|
@@ -1389,7 +1770,9 @@ class CanvasStateManager {
|
|
|
1389
1770
|
deleteBoard(id: string): boolean {
|
|
1390
1771
|
if (!this._db) return false;
|
|
1391
1772
|
if (id === this._activeBoardId && !this.switchBoard(null)) return false;
|
|
1392
|
-
|
|
1773
|
+
// Reverse relations must include outgoing links still debounced on the open board.
|
|
1774
|
+
if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
|
|
1775
|
+
return deleteBoardFromDB(this._db, id, (boardId, nodeId) => this.invalidateRelationTarget(boardId, nodeId));
|
|
1393
1776
|
}
|
|
1394
1777
|
|
|
1395
1778
|
/**
|
|
@@ -1410,6 +1793,7 @@ class CanvasStateManager {
|
|
|
1410
1793
|
if (id) {
|
|
1411
1794
|
const state = loadStateFromDB(this._db, id);
|
|
1412
1795
|
if (!state) return false;
|
|
1796
|
+
this.initializePersistedWikiLinks(state, id);
|
|
1413
1797
|
next = state;
|
|
1414
1798
|
}
|
|
1415
1799
|
this.applyPersistedState(next);
|
|
@@ -1476,6 +1860,22 @@ class CanvasStateManager {
|
|
|
1476
1860
|
}
|
|
1477
1861
|
}
|
|
1478
1862
|
|
|
1863
|
+
private persistedState(): PersistedCanvasState {
|
|
1864
|
+
return this.externalizePersistedStateBlobs({
|
|
1865
|
+
version: 1,
|
|
1866
|
+
theme: this._theme,
|
|
1867
|
+
tour: this._tour,
|
|
1868
|
+
viewport: this._viewport,
|
|
1869
|
+
nodes: Array.from(this.nodes.values()),
|
|
1870
|
+
edges: Array.from(this.edges.values()),
|
|
1871
|
+
annotations: Array.from(this.annotations.values()),
|
|
1872
|
+
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1873
|
+
contextPinMeta: this.pinMetaRecord(),
|
|
1874
|
+
ax: this.getAxState(),
|
|
1875
|
+
revisionState: this.revisionState(),
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1479
1879
|
/** Write current state to SQLite immediately. */
|
|
1480
1880
|
private saveToDisk(): boolean {
|
|
1481
1881
|
if (!this._db) return false;
|
|
@@ -1487,20 +1887,7 @@ class CanvasStateManager {
|
|
|
1487
1887
|
}
|
|
1488
1888
|
const boardId = this.ensureActiveBoard();
|
|
1489
1889
|
if (!boardId) return false;
|
|
1490
|
-
|
|
1491
|
-
version: 1,
|
|
1492
|
-
theme: this._theme,
|
|
1493
|
-
tour: this._tour,
|
|
1494
|
-
viewport: this._viewport,
|
|
1495
|
-
nodes: Array.from(this.nodes.values()),
|
|
1496
|
-
edges: Array.from(this.edges.values()),
|
|
1497
|
-
annotations: Array.from(this.annotations.values()),
|
|
1498
|
-
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1499
|
-
contextPinMeta: this.pinMetaRecord(),
|
|
1500
|
-
ax: this.getAxState(),
|
|
1501
|
-
revisionState: this.revisionState(),
|
|
1502
|
-
});
|
|
1503
|
-
saveStateToDB(this._db, boardId, payload);
|
|
1890
|
+
saveStateToDB(this._db, boardId, this.persistedState());
|
|
1504
1891
|
this._lastPersistenceError = null;
|
|
1505
1892
|
return true;
|
|
1506
1893
|
} catch (error) {
|
|
@@ -1553,6 +1940,10 @@ class CanvasStateManager {
|
|
|
1553
1940
|
}
|
|
1554
1941
|
|
|
1555
1942
|
private applyPersistedState(state: PersistedCanvasState): void {
|
|
1943
|
+
// Validate the complete replacement before discarding any live board state.
|
|
1944
|
+
const nodes = (Array.isArray(state.nodes) ? state.nodes : [])
|
|
1945
|
+
.filter((node) => node?.id)
|
|
1946
|
+
.map((node) => structuredClone(this.normalizeNode(node)));
|
|
1556
1947
|
this._boardGeneration += 1;
|
|
1557
1948
|
this._tour = state.tour ? structuredClone(state.tour) : undefined;
|
|
1558
1949
|
this.nodes.clear();
|
|
@@ -1572,13 +1963,7 @@ class CanvasStateManager {
|
|
|
1572
1963
|
};
|
|
1573
1964
|
this._theme = normalizeCanvasTheme(state.theme, this._theme);
|
|
1574
1965
|
|
|
1575
|
-
|
|
1576
|
-
for (const node of state.nodes) {
|
|
1577
|
-
if (node?.id) {
|
|
1578
|
-
this.nodes.set(node.id, structuredClone(this.normalizeNode(node)));
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
}
|
|
1966
|
+
for (const node of nodes) this.nodes.set(node.id, node);
|
|
1582
1967
|
if (Array.isArray(state.edges)) {
|
|
1583
1968
|
for (const edge of state.edges) {
|
|
1584
1969
|
if (edge?.id) this.edges.set(edge.id, structuredClone(edge));
|
|
@@ -1807,7 +2192,15 @@ class CanvasStateManager {
|
|
|
1807
2192
|
tour: resolved.state.tour,
|
|
1808
2193
|
theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
|
|
1809
2194
|
viewport: structuredClone(resolved.state.viewport),
|
|
1810
|
-
nodes: Array.isArray(resolved.state.nodes)
|
|
2195
|
+
nodes: Array.isArray(resolved.state.nodes)
|
|
2196
|
+
? resolved.state.nodes.map((node) => {
|
|
2197
|
+
const cloned = structuredClone(node);
|
|
2198
|
+
const current = this.nodes.get(cloned.id);
|
|
2199
|
+
return cloned.type === 'markdown' && cloned.data.wikiLinks === undefined
|
|
2200
|
+
? this.bindWikiLinks(this.resolveNodeDataBlobs(cloned), current)
|
|
2201
|
+
: cloned;
|
|
2202
|
+
})
|
|
2203
|
+
: [],
|
|
1811
2204
|
edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
|
|
1812
2205
|
annotations: Array.isArray(resolved.state.annotations)
|
|
1813
2206
|
? resolved.state.annotations.map((annotation) => structuredClone(annotation))
|
|
@@ -1821,7 +2214,7 @@ class CanvasStateManager {
|
|
|
1821
2214
|
try {
|
|
1822
2215
|
const revisionBeforeRestore = this._contentRevision;
|
|
1823
2216
|
this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
|
|
1824
|
-
this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
|
|
2217
|
+
this.restampRestoredContent(previousState.nodes, previousState.edges, revisionBeforeRestore);
|
|
1825
2218
|
this.scheduleSave();
|
|
1826
2219
|
this.notifyChange('nodes');
|
|
1827
2220
|
this.notifyChange('pins');
|
|
@@ -1831,9 +2224,10 @@ class CanvasStateManager {
|
|
|
1831
2224
|
description: `Restored snapshot "${resolved.snapshot.name}"`,
|
|
1832
2225
|
forward: this.suppressed(() => {
|
|
1833
2226
|
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
2227
|
+
const edgesBefore = this.getEdges();
|
|
1834
2228
|
const revisionBeforeReplay = this._contentRevision;
|
|
1835
2229
|
this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
|
|
1836
|
-
this.restampRestoredContent(before, revisionBeforeReplay);
|
|
2230
|
+
this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
|
|
1837
2231
|
this.scheduleSave();
|
|
1838
2232
|
this.notifyChange('nodes');
|
|
1839
2233
|
this.notifyChange('pins');
|
|
@@ -1841,9 +2235,10 @@ class CanvasStateManager {
|
|
|
1841
2235
|
}),
|
|
1842
2236
|
inverse: this.suppressed(() => {
|
|
1843
2237
|
const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
|
|
2238
|
+
const edgesBefore = this.getEdges();
|
|
1844
2239
|
const revisionBeforeReplay = this._contentRevision;
|
|
1845
2240
|
this.applyPersistedState({ ...previousState, revisionState: this.revisionState() });
|
|
1846
|
-
this.restampRestoredContent(before, revisionBeforeReplay);
|
|
2241
|
+
this.restampRestoredContent(before, edgesBefore, revisionBeforeReplay);
|
|
1847
2242
|
this.scheduleSave();
|
|
1848
2243
|
this.notifyChange('nodes');
|
|
1849
2244
|
this.notifyChange('pins');
|
|
@@ -1950,37 +2345,178 @@ class CanvasStateManager {
|
|
|
1950
2345
|
|
|
1951
2346
|
// ── Node CRUD ──────────────────────────────────────────────
|
|
1952
2347
|
|
|
1953
|
-
private restampRestoredContent(
|
|
2348
|
+
private restampRestoredContent(
|
|
2349
|
+
beforeNodes: CanvasNodeState[],
|
|
2350
|
+
beforeEdges: CanvasEdge[],
|
|
2351
|
+
minimumRevision: number,
|
|
2352
|
+
): void {
|
|
1954
2353
|
this._contentRevision = Math.max(this._contentRevision, minimumRevision);
|
|
1955
2354
|
const before = new Map(beforeNodes.map((node) => [node.id, node]));
|
|
1956
2355
|
const restoredIds = new Set(this.nodes.keys());
|
|
1957
2356
|
for (const [id, node] of this.nodes) {
|
|
1958
2357
|
const old = before.get(id);
|
|
2358
|
+
const linksRevision = old ? old.linksRevision : node.linksRevision;
|
|
1959
2359
|
if (old && !this.nodeContentChanged(old, node)) {
|
|
1960
|
-
this.nodes.set(id, {
|
|
2360
|
+
this.nodes.set(id, {
|
|
2361
|
+
...node,
|
|
2362
|
+
contentRevision: old.contentRevision,
|
|
2363
|
+
contentChangedAt: old.contentChangedAt,
|
|
2364
|
+
linksRevision,
|
|
2365
|
+
lastEditedBy: old.lastEditedBy,
|
|
2366
|
+
});
|
|
1961
2367
|
continue;
|
|
1962
2368
|
}
|
|
1963
2369
|
const revision = this.nextContentRevision();
|
|
1964
|
-
this.nodes.set(id, {
|
|
2370
|
+
this.nodes.set(id, {
|
|
2371
|
+
...node,
|
|
2372
|
+
lastEditedBy: currentActor(),
|
|
2373
|
+
contentRevision: revision,
|
|
2374
|
+
contentChangedAt: new Date().toISOString(),
|
|
2375
|
+
linksRevision,
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
for (const id of new Set([...before.keys(), ...restoredIds]))
|
|
2379
|
+
this.touchWikiRelations(before.get(id), this.nodes.get(id));
|
|
2380
|
+
for (const id of new Set([...before.keys(), ...restoredIds])) {
|
|
2381
|
+
if (before.has(id) !== restoredIds.has(id)) this.invalidateIncomingRelations(id);
|
|
1965
2382
|
}
|
|
1966
2383
|
for (const old of beforeNodes) {
|
|
1967
2384
|
if (!restoredIds.has(old.id)) this.recordDeletion(old.id);
|
|
1968
2385
|
}
|
|
2386
|
+
// A link the restore added, retyped, relabelled or removed is a link change on both ends.
|
|
2387
|
+
// It comes back as it was saved, author included, under a new revision.
|
|
2388
|
+
const relation = (edge: CanvasEdge) =>
|
|
2389
|
+
JSON.stringify([edge.from, edge.toBoardId, edge.to, edge.type, edge.kind, edge.label, edge.reason]);
|
|
2390
|
+
const edgesBefore = new Map(beforeEdges.map((edge) => [edge.id, edge]));
|
|
2391
|
+
for (const [id, edge] of this.edges) {
|
|
2392
|
+
const old = edgesBefore.get(id);
|
|
2393
|
+
if (old && relation(old) === relation(edge)) {
|
|
2394
|
+
this.edges.set(id, { ...edgeWithoutStamp(edge), ...edgeStamp(old) });
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
if (old && (old.from !== edge.from || old.to !== edge.to || old.toBoardId !== edge.toBoardId))
|
|
2398
|
+
this.touchRelations(old);
|
|
2399
|
+
const stamp = this.touchRelations(edge);
|
|
2400
|
+
this.edges.set(
|
|
2401
|
+
id,
|
|
2402
|
+
edge.changedBy
|
|
2403
|
+
? { ...edge, ...(stamp.revision !== undefined ? { revision: stamp.revision } : {}) }
|
|
2404
|
+
: { ...edge, ...stamp },
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
for (const old of beforeEdges) {
|
|
2408
|
+
if (!this.edges.has(old.id)) this.touchRelations(old);
|
|
2409
|
+
}
|
|
1969
2410
|
}
|
|
1970
2411
|
|
|
1971
2412
|
private semanticData(data: Record<string, unknown>): Record<string, unknown> {
|
|
1972
2413
|
const copy = { ...data };
|
|
1973
2414
|
delete copy.strictSize;
|
|
1974
2415
|
delete copy.arrangeLocked;
|
|
2416
|
+
delete copy.lifecycleOverride;
|
|
2417
|
+
delete copy.fetchedAt;
|
|
2418
|
+
if (copy.provenance && typeof copy.provenance === 'object') {
|
|
2419
|
+
const provenance = { ...(copy.provenance as Record<string, unknown>) };
|
|
2420
|
+
delete provenance.syncedAt;
|
|
2421
|
+
copy.provenance = provenance;
|
|
2422
|
+
}
|
|
1975
2423
|
return copy;
|
|
1976
2424
|
}
|
|
1977
2425
|
|
|
2426
|
+
/** A small name/ID catalog, read only when Markdown is saved, never on a drag frame. */
|
|
2427
|
+
wikiCatalog(): WikiBoard[] {
|
|
2428
|
+
return this.listBoards().map((board) => ({
|
|
2429
|
+
id: board.id,
|
|
2430
|
+
name: board.name,
|
|
2431
|
+
cards: (board.id === this._activeBoardId
|
|
2432
|
+
? [...this.nodes.values()]
|
|
2433
|
+
: this._db
|
|
2434
|
+
? (loadStateFromDB(this._db, board.id)?.nodes ?? [])
|
|
2435
|
+
: []
|
|
2436
|
+
).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
|
|
2437
|
+
}));
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/** Backfill only uninitialized live rows, not snapshots or authoritative empty/unresolved bindings. */
|
|
2441
|
+
private initializePersistedWikiLinks(state: PersistedCanvasState, boardId: string): void {
|
|
2442
|
+
const legacy = state.nodes.filter((node) => node.type === 'markdown' && node.data.wikiLinks === undefined);
|
|
2443
|
+
if (!legacy.length || !this._db) return;
|
|
2444
|
+
// Read the persisted catalog directly: readBoard would recursively initialize other boards.
|
|
2445
|
+
const catalog = listBoardsFromDB(this._db).map((board) => ({
|
|
2446
|
+
id: board.id,
|
|
2447
|
+
name: board.name,
|
|
2448
|
+
cards: (board.id === this._activeBoardId && board.id !== boardId
|
|
2449
|
+
? [...this.nodes.values()]
|
|
2450
|
+
: board.id === boardId
|
|
2451
|
+
? state.nodes
|
|
2452
|
+
: (loadStateFromDB(this._db!, board.id)?.nodes ?? [])
|
|
2453
|
+
).map((node) => ({ id: node.id, title: typeof node.data.title === 'string' ? node.data.title : node.id })),
|
|
2454
|
+
}));
|
|
2455
|
+
this._db.transaction(() => {
|
|
2456
|
+
for (const node of legacy) {
|
|
2457
|
+
const resolved = this.resolveNodeDataBlobs(node);
|
|
2458
|
+
const content = typeof resolved.data.content === 'string' ? resolved.data.content : '';
|
|
2459
|
+
node.data = { ...node.data, wikiLinks: resolveWikiLinks(content, [], catalog) };
|
|
2460
|
+
this._db!.run('UPDATE nodes SET data = ? WHERE board_id = ? AND id = ?', [
|
|
2461
|
+
JSON.stringify(node.data),
|
|
2462
|
+
boardId,
|
|
2463
|
+
node.id,
|
|
2464
|
+
]);
|
|
2465
|
+
}
|
|
2466
|
+
})();
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
private bindWikiLinks(
|
|
2470
|
+
node: CanvasNodeState,
|
|
2471
|
+
existing?: CanvasNodeState,
|
|
2472
|
+
savedWikiLinks?: WikiLink[],
|
|
2473
|
+
savingContent = false,
|
|
2474
|
+
): CanvasNodeState {
|
|
2475
|
+
if (node.type !== 'markdown') return node;
|
|
2476
|
+
if (savedWikiLinks !== undefined) {
|
|
2477
|
+
const data = { ...node.data };
|
|
2478
|
+
data.wikiLinks = structuredClone(savedWikiLinks);
|
|
2479
|
+
return { ...node, data };
|
|
2480
|
+
}
|
|
2481
|
+
if (
|
|
2482
|
+
!savingContent &&
|
|
2483
|
+
existing?.type === node.type &&
|
|
2484
|
+
existing.data.content === node.data.content &&
|
|
2485
|
+
existing.data.wikiLinks !== undefined
|
|
2486
|
+
) {
|
|
2487
|
+
const data = { ...node.data };
|
|
2488
|
+
data.wikiLinks = structuredClone(existing.data.wikiLinks);
|
|
2489
|
+
return { ...node, data };
|
|
2490
|
+
}
|
|
2491
|
+
const content = typeof node.data.content === 'string' ? node.data.content : '';
|
|
2492
|
+
const previous = existing ? wikiLinksFromData(existing.data) : [];
|
|
2493
|
+
const initializedRefs =
|
|
2494
|
+
existing?.data.wikiLinks !== undefined && typeof existing.data.content === 'string'
|
|
2495
|
+
? wikiReferences(existing.data.content)
|
|
2496
|
+
: [];
|
|
2497
|
+
const refs = wikiReferences(content).filter(
|
|
2498
|
+
(ref) => !initializedRefs.includes(ref) || previous.some((link) => link.ref === ref),
|
|
2499
|
+
);
|
|
2500
|
+
const needsResolution = refs.some(
|
|
2501
|
+
(ref) => !previous.some((link) => link.ref === ref && (!savingContent || link.target)),
|
|
2502
|
+
);
|
|
2503
|
+
const catalog = needsResolution ? this.wikiCatalog() : [];
|
|
2504
|
+
const links = refs.map((ref) => {
|
|
2505
|
+
const saved = previous.find((link) => link.ref === ref);
|
|
2506
|
+
return saved && (!savingContent || saved.target) ? saved : resolveWikiLinks(`[[${ref}]]`, [], catalog)[0];
|
|
2507
|
+
});
|
|
2508
|
+
const data = { ...node.data };
|
|
2509
|
+
data.wikiLinks = links;
|
|
2510
|
+
return { ...node, data };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
1978
2513
|
private nodeContentChanged(a: CanvasNodeState, b: CanvasNodeState): boolean {
|
|
1979
2514
|
return a.type !== b.type || JSON.stringify(this.semanticData(a.data)) !== JSON.stringify(this.semanticData(b.data));
|
|
1980
2515
|
}
|
|
1981
2516
|
|
|
1982
2517
|
private setSemanticNode(node: CanvasNodeState): void {
|
|
1983
2518
|
const existing = this.nodes.get(node.id);
|
|
2519
|
+
node = this.bindWikiLinks(node, existing);
|
|
1984
2520
|
if (!existing || !this.nodeContentChanged(existing, node)) {
|
|
1985
2521
|
this.nodes.set(node.id, node);
|
|
1986
2522
|
return;
|
|
@@ -1994,6 +2530,7 @@ class CanvasStateManager {
|
|
|
1994
2530
|
createdBy: existing.createdBy,
|
|
1995
2531
|
lastEditedBy: currentActor(),
|
|
1996
2532
|
contentRevision: this.nextContentRevision(),
|
|
2533
|
+
contentChangedAt: new Date().toISOString(),
|
|
1997
2534
|
});
|
|
1998
2535
|
}
|
|
1999
2536
|
|
|
@@ -2011,24 +2548,56 @@ class CanvasStateManager {
|
|
|
2011
2548
|
return structuredClone(this._viewport);
|
|
2012
2549
|
}
|
|
2013
2550
|
|
|
2014
|
-
addNode(node: CanvasNodeState): void {
|
|
2551
|
+
addNode(node: CanvasNodeState, savedWikiLinks?: WikiLink[]): void {
|
|
2552
|
+
node = this.normalizeNode(node);
|
|
2015
2553
|
const actor = currentActor();
|
|
2554
|
+
const now = new Date().toISOString();
|
|
2555
|
+
const provenance = node.data.provenance as { sourceKind?: string } | undefined;
|
|
2556
|
+
const imported =
|
|
2557
|
+
node.data.contentOrigin === 'imported' ||
|
|
2558
|
+
node.data.source !== undefined ||
|
|
2559
|
+
(provenance !== undefined && provenance.sourceKind !== 'recipe');
|
|
2016
2560
|
const cloned = structuredClone(
|
|
2017
|
-
this.normalizeNode(
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2561
|
+
this.normalizeNode(
|
|
2562
|
+
this.bindWikiLinks(
|
|
2563
|
+
{
|
|
2564
|
+
...node,
|
|
2565
|
+
createdBy: actor,
|
|
2566
|
+
lastEditedBy: actor,
|
|
2567
|
+
contentRevision: this.nextContentRevision(),
|
|
2568
|
+
contentChangedAt: now,
|
|
2569
|
+
freshnessStartedAt: imported ? undefined : now,
|
|
2570
|
+
},
|
|
2571
|
+
undefined,
|
|
2572
|
+
savedWikiLinks,
|
|
2573
|
+
),
|
|
2574
|
+
),
|
|
2023
2575
|
);
|
|
2024
2576
|
this.nodes.set(node.id, cloned);
|
|
2577
|
+
this.touchWikiRelations(undefined, cloned);
|
|
2578
|
+
this.invalidateIncomingRelations(node.id);
|
|
2025
2579
|
this.scheduleSave();
|
|
2026
2580
|
this.notifyChange('nodes');
|
|
2581
|
+
this.recordNodeCreation(cloned);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
private recordNodeCreation(cloned: CanvasNodeState): void {
|
|
2027
2585
|
this.recordMutation({
|
|
2028
2586
|
operationType: 'addNode',
|
|
2029
|
-
description: `Added ${
|
|
2030
|
-
forward: this.suppressed(() =>
|
|
2031
|
-
|
|
2587
|
+
description: `Added ${cloned.type} node "${(cloned.data.title as string) ?? cloned.id}"`,
|
|
2588
|
+
forward: this.suppressed(() => {
|
|
2589
|
+
this.nodes.set(cloned.id, {
|
|
2590
|
+
...structuredClone(cloned),
|
|
2591
|
+
contentRevision: this.nextContentRevision(),
|
|
2592
|
+
contentChangedAt: new Date().toISOString(),
|
|
2593
|
+
lastEditedBy: currentActor(),
|
|
2594
|
+
});
|
|
2595
|
+
this.touchWikiRelations(undefined, this.nodes.get(cloned.id));
|
|
2596
|
+
this.invalidateIncomingRelations(cloned.id);
|
|
2597
|
+
this.scheduleSave();
|
|
2598
|
+
this.notifyChange('nodes');
|
|
2599
|
+
}),
|
|
2600
|
+
inverse: this.suppressed(() => this.removeNode(cloned.id)),
|
|
2032
2601
|
});
|
|
2033
2602
|
}
|
|
2034
2603
|
|
|
@@ -2040,10 +2609,39 @@ class CanvasStateManager {
|
|
|
2040
2609
|
this.addNode(node);
|
|
2041
2610
|
}
|
|
2042
2611
|
|
|
2043
|
-
|
|
2612
|
+
/** Metadata stays on the stored node: no blob hydration, wiki resolution or group reflow. */
|
|
2613
|
+
setNodeLifecycle(id: string, value: Lifecycle | null): void {
|
|
2614
|
+
const existing = this.nodes.get(id);
|
|
2615
|
+
if (!existing) throw new OperationError('Card not found.', 404);
|
|
2616
|
+
const lifecycle = value === null ? null : lifecycleSchema.parse(value);
|
|
2617
|
+
const previous = structuredClone(existing.data.lifecycleOverride) as Lifecycle | null | undefined;
|
|
2618
|
+
const apply = (override: Lifecycle | null | undefined) => {
|
|
2619
|
+
const node = this.nodes.get(id);
|
|
2620
|
+
if (!node) return;
|
|
2621
|
+
const data = { ...node.data };
|
|
2622
|
+
if (override === undefined) delete data.lifecycleOverride;
|
|
2623
|
+
else data.lifecycleOverride = structuredClone(override);
|
|
2624
|
+
this.nodes.set(id, { ...node, data });
|
|
2625
|
+
this.scheduleSave();
|
|
2626
|
+
this.notifyChange('nodes');
|
|
2627
|
+
};
|
|
2628
|
+
apply(lifecycle);
|
|
2629
|
+
this.recordMutation({
|
|
2630
|
+
operationType: 'updateNode',
|
|
2631
|
+
description: `Changed lifecycle of "${existing.data.title ?? id}"`,
|
|
2632
|
+
forward: this.suppressed(() => apply(lifecycle)),
|
|
2633
|
+
inverse: this.suppressed(() => apply(previous)),
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// null replays a legacy snapshot using existing records before resolving new refs.
|
|
2638
|
+
updateNode(id: string, patch: Partial<CanvasNodeState>, savedWikiLinks?: WikiLink[] | null): void {
|
|
2044
2639
|
const existing = this.nodes.get(id);
|
|
2045
2640
|
if (!existing) return;
|
|
2046
2641
|
if (patch.data) {
|
|
2642
|
+
if (patch.data.contentOrigin !== existing.data.contentOrigin) {
|
|
2643
|
+
throw new OperationError('Content origin is immutable after creation.');
|
|
2644
|
+
}
|
|
2047
2645
|
const attachmentId = existing.data.attachmentId;
|
|
2048
2646
|
if (typeof attachmentId === 'string' && patch.data.attachmentId !== attachmentId) {
|
|
2049
2647
|
throw new Error('Attachment identity is immutable.');
|
|
@@ -2056,10 +2654,17 @@ class CanvasStateManager {
|
|
|
2056
2654
|
}
|
|
2057
2655
|
}
|
|
2058
2656
|
const oldSnapshot = structuredClone(existing);
|
|
2657
|
+
let nextNode = this.normalizeNode(
|
|
2658
|
+
this.bindWikiLinks(
|
|
2659
|
+
{ ...existing, ...patch, freshnessStartedAt: existing.freshnessStartedAt },
|
|
2660
|
+
existing,
|
|
2661
|
+
savedWikiLinks ?? undefined,
|
|
2662
|
+
savedWikiLinks !== null && !!patch.data && 'content' in patch.data,
|
|
2663
|
+
),
|
|
2664
|
+
);
|
|
2059
2665
|
if (existing.type === 'group' && patch.position) {
|
|
2060
2666
|
this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
|
|
2061
2667
|
}
|
|
2062
|
-
let nextNode = this.normalizeNode({ ...existing, ...patch });
|
|
2063
2668
|
if (this.nodeContentChanged(existing, nextNode)) {
|
|
2064
2669
|
const data = { ...nextNode.data };
|
|
2065
2670
|
const suppliedData = patch.data;
|
|
@@ -2071,6 +2676,7 @@ class CanvasStateManager {
|
|
|
2071
2676
|
createdBy: existing.createdBy ?? unknownActor(),
|
|
2072
2677
|
lastEditedBy: currentActor(),
|
|
2073
2678
|
contentRevision: this.nextContentRevision(),
|
|
2679
|
+
contentChangedAt: new Date().toISOString(),
|
|
2074
2680
|
};
|
|
2075
2681
|
} else {
|
|
2076
2682
|
nextNode = {
|
|
@@ -2078,9 +2684,11 @@ class CanvasStateManager {
|
|
|
2078
2684
|
createdBy: existing.createdBy,
|
|
2079
2685
|
lastEditedBy: existing.lastEditedBy,
|
|
2080
2686
|
contentRevision: existing.contentRevision,
|
|
2687
|
+
contentChangedAt: existing.contentChangedAt,
|
|
2081
2688
|
};
|
|
2082
2689
|
}
|
|
2083
2690
|
this.nodes.set(id, nextNode);
|
|
2691
|
+
this.touchWikiRelations(existing, nextNode);
|
|
2084
2692
|
const parentGroupId = existing.data.parentGroup as string | undefined;
|
|
2085
2693
|
if (parentGroupId) {
|
|
2086
2694
|
// Moving or resizing a grouped child re-fits the group frame but must NOT
|
|
@@ -2094,18 +2702,29 @@ class CanvasStateManager {
|
|
|
2094
2702
|
this.recordMutation({
|
|
2095
2703
|
operationType: 'updateNode',
|
|
2096
2704
|
description: `Updated node "${(existing.data.title as string) ?? id}"`,
|
|
2097
|
-
forward: this.suppressed(() =>
|
|
2705
|
+
forward: this.suppressed(() =>
|
|
2706
|
+
this.updateNode(
|
|
2707
|
+
id,
|
|
2708
|
+
structuredClone(patch),
|
|
2709
|
+
nextNode.type === 'markdown' ? wikiLinksFromData(nextNode.data) : undefined,
|
|
2710
|
+
),
|
|
2711
|
+
),
|
|
2098
2712
|
inverse: this.suppressed(() => {
|
|
2099
2713
|
const restored = structuredClone(oldSnapshot);
|
|
2100
2714
|
const current = this.nodes.get(id);
|
|
2101
2715
|
if (current && this.nodeContentChanged(current, restored)) {
|
|
2102
2716
|
restored.contentRevision = this.nextContentRevision();
|
|
2717
|
+
restored.contentChangedAt = new Date().toISOString();
|
|
2103
2718
|
restored.lastEditedBy = currentActor();
|
|
2104
2719
|
} else if (current) {
|
|
2105
2720
|
restored.contentRevision = current.contentRevision;
|
|
2721
|
+
restored.contentChangedAt = current.contentChangedAt;
|
|
2106
2722
|
restored.lastEditedBy = current.lastEditedBy;
|
|
2107
2723
|
}
|
|
2724
|
+
// Links are tracked apart: undoing a node change never rolls its links revision back.
|
|
2725
|
+
if (current) restored.linksRevision = current.linksRevision;
|
|
2108
2726
|
this.nodes.set(id, restored);
|
|
2727
|
+
this.touchWikiRelations(current, restored);
|
|
2109
2728
|
this.scheduleSave();
|
|
2110
2729
|
this.notifyChange('nodes');
|
|
2111
2730
|
}),
|
|
@@ -2150,6 +2769,10 @@ class CanvasStateManager {
|
|
|
2150
2769
|
}
|
|
2151
2770
|
}
|
|
2152
2771
|
|
|
2772
|
+
if (existing) {
|
|
2773
|
+
this.touchWikiRelations(existing, undefined);
|
|
2774
|
+
this.invalidateIncomingRelations(id);
|
|
2775
|
+
}
|
|
2153
2776
|
this.nodes.delete(id);
|
|
2154
2777
|
this.removeEdgesForNode(id);
|
|
2155
2778
|
this._contextPinnedNodeIds.delete(id);
|
|
@@ -2200,7 +2823,10 @@ class CanvasStateManager {
|
|
|
2200
2823
|
: `Removed ${cloned.type} node "${title}"`,
|
|
2201
2824
|
forward: this.suppressed(() => this.removeNode(id)),
|
|
2202
2825
|
inverse: this.suppressed(() => {
|
|
2203
|
-
this.addNode(
|
|
2826
|
+
this.addNode(
|
|
2827
|
+
structuredClone(cloned),
|
|
2828
|
+
cloned.type === 'markdown' ? wikiLinksFromData(cloned.data) : undefined,
|
|
2829
|
+
);
|
|
2204
2830
|
const restored = this.nodes.get(id);
|
|
2205
2831
|
if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
|
|
2206
2832
|
// A restored group takes its children back; the enclosing group's membership is restored verbatim.
|
|
@@ -2237,6 +2863,16 @@ class CanvasStateManager {
|
|
|
2237
2863
|
return node ? structuredClone(this.nodeForRead(node)) : undefined;
|
|
2238
2864
|
}
|
|
2239
2865
|
|
|
2866
|
+
/** Who made and last edited each card, and its content revision — no content, no copies. */
|
|
2867
|
+
nodeStamps(): Array<Pick<CanvasNodeState, 'id' | 'createdBy' | 'lastEditedBy' | 'contentRevision'>> {
|
|
2868
|
+
return [...this.nodes.values()].map(({ id, createdBy, lastEditedBy, contentRevision }) => ({
|
|
2869
|
+
id,
|
|
2870
|
+
createdBy,
|
|
2871
|
+
lastEditedBy,
|
|
2872
|
+
contentRevision,
|
|
2873
|
+
}));
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2240
2876
|
getNodeForPersistence(id: string): CanvasNodeState | undefined {
|
|
2241
2877
|
const node = this.nodes.get(id);
|
|
2242
2878
|
return node ? structuredClone(this.externalizeNodeDataBlobs(node)) : undefined;
|
|
@@ -2244,15 +2880,100 @@ class CanvasStateManager {
|
|
|
2244
2880
|
|
|
2245
2881
|
// ── Edge CRUD ──────────────────────────────────────────────
|
|
2246
2882
|
|
|
2883
|
+
/**
|
|
2884
|
+
* Links are tracked apart from text (connection changes, option C of
|
|
2885
|
+
* docs/design/LinksOptions.dc.html): a link change advances both endpoints'
|
|
2886
|
+
* links revision, never their content revision, so cards keep their read
|
|
2887
|
+
* marks while the next incremental brief resends them with their new
|
|
2888
|
+
* relations. Generated auto-edges (code graph, board map) do not count.
|
|
2889
|
+
* Returns the stamp for the changed link.
|
|
2890
|
+
*/
|
|
2891
|
+
private touchRelations(
|
|
2892
|
+
edge: Pick<CanvasEdge, 'id' | 'from' | 'to' | 'toBoardId'>,
|
|
2893
|
+
): Pick<CanvasEdge, 'revision' | 'changedBy' | 'changedAt'> {
|
|
2894
|
+
if (edge.id.startsWith('codegraph-') || edge.id.startsWith('boardmap-')) return {};
|
|
2895
|
+
const revision = this.nextContentRevision();
|
|
2896
|
+
for (const id of [edge.from, ...(!edge.toBoardId || edge.toBoardId === this._activeBoardId ? [edge.to] : [])]) {
|
|
2897
|
+
const node = this.nodes.get(id);
|
|
2898
|
+
if (node) this.nodes.set(id, { ...node, linksRevision: revision });
|
|
2899
|
+
}
|
|
2900
|
+
if (edge.toBoardId && edge.toBoardId !== this._activeBoardId)
|
|
2901
|
+
this.invalidateRelationTarget(edge.toBoardId, edge.to);
|
|
2902
|
+
return { revision, changedBy: currentActor(), changedAt: new Date().toISOString() };
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
private invalidateRelationTarget(boardId: string, nodeId: string): void {
|
|
2906
|
+
if (boardId === this._activeBoardId) {
|
|
2907
|
+
const node = this.nodes.get(nodeId);
|
|
2908
|
+
if (node) {
|
|
2909
|
+
this.nodes.set(nodeId, { ...node, linksRevision: this.nextContentRevision() });
|
|
2910
|
+
this.scheduleSave();
|
|
2911
|
+
this.notifyChange('nodes');
|
|
2912
|
+
}
|
|
2913
|
+
} else if (this._db) invalidateLinksInDB(this._db, boardId, nodeId);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
/** Saved wiki relationships have the same source/target clocks as native links. */
|
|
2917
|
+
private touchWikiRelations(before?: CanvasNodeState, after?: CanvasNodeState): void {
|
|
2918
|
+
const previous = before?.type === 'markdown' ? wikiLinksFromData(before.data) : [];
|
|
2919
|
+
const next = after?.type === 'markdown' ? wikiLinksFromData(after.data) : [];
|
|
2920
|
+
if (JSON.stringify(previous) === JSON.stringify(next)) return;
|
|
2921
|
+
const source = after ?? before;
|
|
2922
|
+
if (source && this._activeBoardId) this.invalidateRelationTarget(this._activeBoardId, source.id);
|
|
2923
|
+
const targets = new Map(
|
|
2924
|
+
[...previous, ...next].flatMap(({ target }) =>
|
|
2925
|
+
target?.cardId ? [[JSON.stringify(target), target] as const] : [],
|
|
2926
|
+
),
|
|
2927
|
+
);
|
|
2928
|
+
for (const target of targets.values()) this.invalidateRelationTarget(target.boardId, target.cardId!);
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
private invalidateIncomingRelations(nodeId: string): void {
|
|
2932
|
+
for (const source of this.nodes.values()) {
|
|
2933
|
+
if (
|
|
2934
|
+
source.type === 'markdown' &&
|
|
2935
|
+
wikiLinksFromData(source.data).some(
|
|
2936
|
+
({ target }) => target?.boardId === this._activeBoardId && target.cardId === nodeId,
|
|
2937
|
+
)
|
|
2938
|
+
)
|
|
2939
|
+
this.invalidateRelationTarget(this._activeBoardId!, source.id);
|
|
2940
|
+
}
|
|
2941
|
+
if (this._db && this._activeBoardId)
|
|
2942
|
+
invalidateIncomingLinksInDB(this._db, this._activeBoardId, nodeId, (boardId, sourceId) =>
|
|
2943
|
+
this.invalidateRelationTarget(boardId, sourceId),
|
|
2944
|
+
);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2247
2947
|
addEdge(edge: CanvasEdge): boolean {
|
|
2248
|
-
if (edge.from === edge.to) return false;
|
|
2948
|
+
if (!edge.toBoardId && edge.from === edge.to) return false;
|
|
2249
2949
|
for (const existing of this.edges.values()) {
|
|
2250
|
-
if (
|
|
2950
|
+
if (
|
|
2951
|
+
existing.from === edge.from &&
|
|
2952
|
+
existing.to === edge.to &&
|
|
2953
|
+
existing.toBoardId === edge.toBoardId &&
|
|
2954
|
+
existing.type === edge.type &&
|
|
2955
|
+
existing.kind === edge.kind
|
|
2956
|
+
) {
|
|
2251
2957
|
return false;
|
|
2252
2958
|
}
|
|
2253
2959
|
}
|
|
2254
|
-
|
|
2255
|
-
this.
|
|
2960
|
+
if (!edge.changedBy) edge = { ...edge, sourceRevision: this.relationSourceRevision(edge) };
|
|
2961
|
+
const stamp = this.touchRelations(edge);
|
|
2962
|
+
// A link that arrives with an author is being restored (undo of a removal, a
|
|
2963
|
+
// demo seed): it keeps its authorship and only takes a new revision. The API
|
|
2964
|
+
// builds new links from scratch, so a new link is always the caller's.
|
|
2965
|
+
this.edges.set(
|
|
2966
|
+
edge.id,
|
|
2967
|
+
edge.changedBy
|
|
2968
|
+
? { ...edge, ...(stamp.revision !== undefined ? { revision: stamp.revision } : {}) }
|
|
2969
|
+
: {
|
|
2970
|
+
...edge,
|
|
2971
|
+
...stamp,
|
|
2972
|
+
...(stamp.changedBy ? { createdBy: stamp.changedBy, createdAt: stamp.changedAt } : {}),
|
|
2973
|
+
},
|
|
2974
|
+
);
|
|
2975
|
+
// Redo replays the link as stored, with its author.
|
|
2976
|
+
const cloned = structuredClone(this.edges.get(edge.id) as CanvasEdge);
|
|
2256
2977
|
this.scheduleSave();
|
|
2257
2978
|
this.notifyChange('nodes');
|
|
2258
2979
|
this.recordMutation({
|
|
@@ -2264,25 +2985,54 @@ class CanvasStateManager {
|
|
|
2264
2985
|
return true;
|
|
2265
2986
|
}
|
|
2266
2987
|
|
|
2267
|
-
|
|
2988
|
+
private relationSourceRevision(edge: CanvasEdge): number | undefined {
|
|
2989
|
+
const end = relationSourceEnd(edge);
|
|
2990
|
+
if (!end) return undefined;
|
|
2991
|
+
const node =
|
|
2992
|
+
end === 'to' && edge.toBoardId && edge.toBoardId !== this._activeBoardId
|
|
2993
|
+
? this.readBoard(edge.toBoardId, false)?.layout.nodes.find((item) => item.id === edge.to)
|
|
2994
|
+
: this.nodes.get(end === 'from' ? edge.from : edge.to);
|
|
2995
|
+
return node?.contentRevision;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
updateEdge(
|
|
2999
|
+
id: string,
|
|
3000
|
+
patch: Partial<Pick<CanvasEdge, 'type' | 'kind' | 'reason' | 'label' | 'style' | 'animated'>>,
|
|
3001
|
+
): CanvasEdge | null {
|
|
2268
3002
|
const existing = this.edges.get(id);
|
|
2269
3003
|
if (!existing) return null;
|
|
2270
3004
|
// Retyping onto an existing from/to/type triple would create the duplicate
|
|
2271
3005
|
// addEdge refuses.
|
|
2272
|
-
|
|
3006
|
+
const candidate = { ...existing, ...patch };
|
|
3007
|
+
if (candidate.type !== existing.type || candidate.kind !== existing.kind) {
|
|
2273
3008
|
for (const other of this.edges.values()) {
|
|
2274
|
-
if (
|
|
3009
|
+
if (
|
|
3010
|
+
other.id !== id &&
|
|
3011
|
+
other.from === existing.from &&
|
|
3012
|
+
other.to === existing.to &&
|
|
3013
|
+
other.toBoardId === existing.toBoardId &&
|
|
3014
|
+
other.type === candidate.type &&
|
|
3015
|
+
other.kind === candidate.kind
|
|
3016
|
+
) {
|
|
2275
3017
|
return null;
|
|
2276
3018
|
}
|
|
2277
3019
|
}
|
|
2278
3020
|
}
|
|
2279
3021
|
const before = structuredClone(existing);
|
|
2280
3022
|
const updated: CanvasEdge = { ...existing, ...patch };
|
|
3023
|
+
if (candidate.type !== existing.type || candidate.kind !== existing.kind)
|
|
3024
|
+
updated.sourceRevision = this.relationSourceRevision(updated);
|
|
2281
3025
|
// An explicit undefined in the patch clears the field (label/style/animated
|
|
2282
3026
|
// are optional on the wire shape — never store undefined values).
|
|
2283
|
-
for (const key of ['label', 'style', 'animated'] as const) {
|
|
3027
|
+
for (const key of ['kind', 'reason', 'label', 'style', 'animated'] as const) {
|
|
2284
3028
|
if (key in patch && patch[key] === undefined) delete updated[key];
|
|
2285
3029
|
}
|
|
3030
|
+
const relationChanged =
|
|
3031
|
+
updated.type !== before.type ||
|
|
3032
|
+
updated.label !== before.label ||
|
|
3033
|
+
updated.kind !== before.kind ||
|
|
3034
|
+
updated.reason !== before.reason;
|
|
3035
|
+
if (relationChanged) Object.assign(updated, this.touchRelations(updated));
|
|
2286
3036
|
const after = structuredClone(updated);
|
|
2287
3037
|
this.edges.set(id, updated);
|
|
2288
3038
|
this.scheduleSave();
|
|
@@ -2291,12 +3041,12 @@ class CanvasStateManager {
|
|
|
2291
3041
|
operationType: 'updateEdge',
|
|
2292
3042
|
description: `Updated ${updated.type} edge ${updated.from} → ${updated.to}`,
|
|
2293
3043
|
forward: this.suppressed(() => {
|
|
2294
|
-
this.edges.set(id,
|
|
3044
|
+
this.edges.set(id, this.replayEdge(after, relationChanged));
|
|
2295
3045
|
this.scheduleSave();
|
|
2296
3046
|
this.notifyChange('nodes');
|
|
2297
3047
|
}),
|
|
2298
3048
|
inverse: this.suppressed(() => {
|
|
2299
|
-
this.edges.set(id,
|
|
3049
|
+
this.edges.set(id, this.replayEdge(before, relationChanged));
|
|
2300
3050
|
this.scheduleSave();
|
|
2301
3051
|
this.notifyChange('nodes');
|
|
2302
3052
|
}),
|
|
@@ -2304,11 +3054,27 @@ class CanvasStateManager {
|
|
|
2304
3054
|
return structuredClone(updated);
|
|
2305
3055
|
}
|
|
2306
3056
|
|
|
3057
|
+
/**
|
|
3058
|
+
* An edge as undo/redo puts it back. A relation change restores the link as it
|
|
3059
|
+
* was, author included, under a new revision so the next brief carries it; a
|
|
3060
|
+
* style-only change keeps the current stamp, so it never hides a later relabel.
|
|
3061
|
+
*/
|
|
3062
|
+
private replayEdge(snapshot: CanvasEdge, relationChanged: boolean): CanvasEdge {
|
|
3063
|
+
const edge = structuredClone(snapshot);
|
|
3064
|
+
if (relationChanged) {
|
|
3065
|
+
const { revision } = this.touchRelations(edge);
|
|
3066
|
+
return revision === undefined ? edge : { ...edge, revision };
|
|
3067
|
+
}
|
|
3068
|
+
const current = this.edges.get(snapshot.id);
|
|
3069
|
+
return { ...edgeWithoutStamp(edge), ...(current ? edgeStamp(current) : {}) };
|
|
3070
|
+
}
|
|
3071
|
+
|
|
2307
3072
|
removeEdge(id: string): boolean {
|
|
2308
3073
|
const existing = this.edges.get(id);
|
|
2309
3074
|
const cloned = existing ? structuredClone(existing) : null;
|
|
2310
3075
|
const removed = this.edges.delete(id);
|
|
2311
3076
|
if (removed && cloned) {
|
|
3077
|
+
this.touchRelations(cloned);
|
|
2312
3078
|
this.scheduleSave();
|
|
2313
3079
|
this.notifyChange('nodes');
|
|
2314
3080
|
this.recordMutation({
|
|
@@ -2327,7 +3093,10 @@ class CanvasStateManager {
|
|
|
2327
3093
|
|
|
2328
3094
|
getEdgesForNode(nodeId: string): CanvasEdge[] {
|
|
2329
3095
|
return Array.from(this.edges.values())
|
|
2330
|
-
.filter(
|
|
3096
|
+
.filter(
|
|
3097
|
+
(edge) =>
|
|
3098
|
+
edge.from === nodeId || ((!edge.toBoardId || edge.toBoardId === this._activeBoardId) && edge.to === nodeId),
|
|
3099
|
+
)
|
|
2331
3100
|
.map((edge) => structuredClone(edge));
|
|
2332
3101
|
}
|
|
2333
3102
|
|
|
@@ -2367,8 +3136,10 @@ class CanvasStateManager {
|
|
|
2367
3136
|
|
|
2368
3137
|
private removeEdgesForNode(nodeId: string): void {
|
|
2369
3138
|
for (const [id, edge] of this.edges) {
|
|
2370
|
-
if (edge.from === nodeId || edge.to === nodeId) {
|
|
3139
|
+
if (edge.from === nodeId || ((!edge.toBoardId || edge.toBoardId === this._activeBoardId) && edge.to === nodeId)) {
|
|
2371
3140
|
this.edges.delete(id);
|
|
3141
|
+
// The card on the other end lost a link.
|
|
3142
|
+
this.touchRelations(edge);
|
|
2372
3143
|
}
|
|
2373
3144
|
}
|
|
2374
3145
|
}
|
|
@@ -2490,7 +3261,8 @@ class CanvasStateManager {
|
|
|
2490
3261
|
}),
|
|
2491
3262
|
inverse: this.suppressed(() => {
|
|
2492
3263
|
for (const snapshot of inverseSnapshots) {
|
|
2493
|
-
this.nodes.
|
|
3264
|
+
const linksRevision = this.nodes.get(snapshot.id)?.linksRevision ?? snapshot.node.linksRevision;
|
|
3265
|
+
this.nodes.set(snapshot.id, { ...structuredClone(snapshot.node), linksRevision });
|
|
2494
3266
|
}
|
|
2495
3267
|
this.reflowAllGroups();
|
|
2496
3268
|
this.scheduleSave();
|
|
@@ -2522,6 +3294,23 @@ class CanvasStateManager {
|
|
|
2522
3294
|
return this._theme;
|
|
2523
3295
|
}
|
|
2524
3296
|
|
|
3297
|
+
getWorkspaceSettings(): WorkspaceSettings {
|
|
3298
|
+
const stored = this._db ? readMetaFromDB(this._db, 'workspace_settings') : null;
|
|
3299
|
+
return stored === null
|
|
3300
|
+
? { ...DEFAULT_WORKSPACE_SETTINGS }
|
|
3301
|
+
: workspaceSettingsSchema.parse({ ...DEFAULT_WORKSPACE_SETTINGS, ...JSON.parse(stored) });
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
updateWorkspaceSettings(patch: Partial<WorkspaceSettings>): WorkspaceSettings {
|
|
3305
|
+
if (!this._db) throw new OperationError('Settings need a workspace database.', 409);
|
|
3306
|
+
const next = workspaceSettingsSchema.parse({
|
|
3307
|
+
...this.getWorkspaceSettings(),
|
|
3308
|
+
...workspaceSettingsPatchSchema.parse(patch),
|
|
3309
|
+
});
|
|
3310
|
+
writeMetaToDB(this._db, 'workspace_settings', JSON.stringify(next));
|
|
3311
|
+
return next;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
2525
3314
|
setTheme(theme: CanvasTheme): CanvasTheme {
|
|
2526
3315
|
const next = normalizeCanvasTheme(theme, this._theme);
|
|
2527
3316
|
if (next === this._theme) return this._theme;
|
|
@@ -2878,7 +3667,11 @@ class CanvasStateManager {
|
|
|
2878
3667
|
recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
|
|
2879
3668
|
if (!this._db) return null;
|
|
2880
3669
|
try {
|
|
2881
|
-
|
|
3670
|
+
// A graph read may mark an inactive target seen while its outgoing source
|
|
3671
|
+
// edges are still debounced in memory. Never book seen before the source.
|
|
3672
|
+
if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
|
|
3673
|
+
const { readNodes, seenLinks } = this.readRevisions(input, boardId);
|
|
3674
|
+
const read = appendContextReadToDB(this._db, input, boardId, readNodes, seenLinks);
|
|
2882
3675
|
this._contextReadListener?.(boardId);
|
|
2883
3676
|
return read;
|
|
2884
3677
|
} catch (error) {
|
|
@@ -2887,18 +3680,41 @@ class CanvasStateManager {
|
|
|
2887
3680
|
}
|
|
2888
3681
|
}
|
|
2889
3682
|
|
|
2890
|
-
/**
|
|
2891
|
-
|
|
2892
|
-
|
|
3683
|
+
/**
|
|
3684
|
+
* The delivered ids that are nodes on the read board: those read in full with
|
|
3685
|
+
* their content revision now, and every one that arrived (summaries included)
|
|
3686
|
+
* with its links revision now.
|
|
3687
|
+
*/
|
|
3688
|
+
private readRevisions(
|
|
3689
|
+
input: ContextReadInput,
|
|
3690
|
+
boardId: string | null,
|
|
3691
|
+
): { readNodes: Record<string, number>; seenLinks: Record<string, number> } {
|
|
3692
|
+
const readIds = input.readNodeIds ?? input.deliveredNodeIds;
|
|
3693
|
+
const seenIds = input.seenNodeIds ?? readIds;
|
|
3694
|
+
if (!boardId || (readIds.length === 0 && seenIds.length === 0)) return { readNodes: {}, seenLinks: {} };
|
|
2893
3695
|
const nodes =
|
|
2894
3696
|
boardId === this._activeBoardId ? [...this.nodes.values()] : (this.readBoard(boardId, false)?.state.nodes ?? []);
|
|
2895
|
-
const byId = new Map(nodes.map((node) => [node.id, node
|
|
2896
|
-
const
|
|
2897
|
-
for (const id of
|
|
2898
|
-
const
|
|
2899
|
-
if (
|
|
3697
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
3698
|
+
const readNodes: Record<string, number> = {};
|
|
3699
|
+
for (const id of readIds) {
|
|
3700
|
+
const node = byId.get(id);
|
|
3701
|
+
if (node) readNodes[id] = node.contentRevision ?? 0;
|
|
3702
|
+
}
|
|
3703
|
+
const seenLinks: Record<string, number> = {};
|
|
3704
|
+
for (const id of seenIds) {
|
|
3705
|
+
const node = byId.get(id);
|
|
3706
|
+
if (!node) continue;
|
|
3707
|
+
const revision = input.seenLinkRevisions ? input.seenLinkRevisions[id] : (node.linksRevision ?? 0);
|
|
3708
|
+
if (revision !== undefined && Number.isSafeInteger(revision) && revision >= 0)
|
|
3709
|
+
seenLinks[id] = Math.min(revision, node.linksRevision ?? 0);
|
|
2900
3710
|
}
|
|
2901
|
-
return
|
|
3711
|
+
return { readNodes, seenLinks };
|
|
3712
|
+
}
|
|
3713
|
+
|
|
3714
|
+
/** Per node on a board, the newest links revision an agent read carried. */
|
|
3715
|
+
getSeenLinks(boardId: string | null = this._activeBoardId): Record<string, number> {
|
|
3716
|
+
if (!this._db || !boardId) return {};
|
|
3717
|
+
return loadSeenLinksFromDB(this._db, boardId);
|
|
2902
3718
|
}
|
|
2903
3719
|
|
|
2904
3720
|
/** Per node on a board, the latest agent read that delivered its content. */
|
|
@@ -2907,6 +3723,12 @@ class CanvasStateManager {
|
|
|
2907
3723
|
return loadNodeReadStatusFromDB(this._db, boardId);
|
|
2908
3724
|
}
|
|
2909
3725
|
|
|
3726
|
+
/** The latest agent read on a board, of any kind, or null. */
|
|
3727
|
+
getBoardLastRead(boardId: string | null = this._activeBoardId): { lastReadAt: string; lastReadBy: string } | null {
|
|
3728
|
+
if (!this._db || !boardId) return null;
|
|
3729
|
+
return loadBoardLastReadFromDB(this._db, boardId);
|
|
3730
|
+
}
|
|
3731
|
+
|
|
2910
3732
|
/** Nodes on a board whose content an agent read at or after `since` (ISO). */
|
|
2911
3733
|
getReadNodeIdsSince(boardId: string | null, since: string): string[] {
|
|
2912
3734
|
if (!this._db || !boardId) return [];
|
|
@@ -3103,7 +3925,12 @@ class CanvasStateManager {
|
|
|
3103
3925
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
3104
3926
|
const oldAxState = this.getAxState();
|
|
3105
3927
|
const oldViewport = { ...this._viewport };
|
|
3106
|
-
for (const
|
|
3928
|
+
for (const edge of oldEdges) this.touchRelations(edge);
|
|
3929
|
+
for (const node of oldNodes) {
|
|
3930
|
+
this.touchWikiRelations(node, undefined);
|
|
3931
|
+
this.invalidateIncomingRelations(node.id);
|
|
3932
|
+
this.recordDeletion(node.id);
|
|
3933
|
+
}
|
|
3107
3934
|
this.nodes.clear();
|
|
3108
3935
|
this.edges.clear();
|
|
3109
3936
|
this.annotations.clear();
|
|
@@ -3123,7 +3950,8 @@ class CanvasStateManager {
|
|
|
3123
3950
|
description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
|
|
3124
3951
|
forward: this.suppressed(() => this.clear()),
|
|
3125
3952
|
inverse: this.suppressed(() => {
|
|
3126
|
-
for (const n of oldNodes)
|
|
3953
|
+
for (const n of oldNodes)
|
|
3954
|
+
this.addNode(structuredClone(n), n.type === 'markdown' ? wikiLinksFromData(n.data) : undefined);
|
|
3127
3955
|
for (const e of oldEdges) this.addEdge(structuredClone(e));
|
|
3128
3956
|
for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
|
|
3129
3957
|
this.setContextPins(oldPins);
|