pmx-canvas 0.6.4 → 0.7.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.
Files changed (204) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +73 -0
  3. package/Readme.md +35 -5
  4. package/dist/canvas/global.css +498 -28
  5. package/dist/canvas/index.js +110 -95
  6. package/dist/canvas/mermaid-entry.js +258 -258
  7. package/dist/types/cli/agent.d.ts +4 -0
  8. package/dist/types/cli/commands/backup.d.ts +1 -0
  9. package/dist/types/cli/commands/boards.d.ts +1 -0
  10. package/dist/types/cli/commands/export.d.ts +1 -0
  11. package/dist/types/cli/commands/record.d.ts +12 -0
  12. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  13. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  14. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  15. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  16. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  17. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  18. package/dist/types/client/canvas/Presentation.d.ts +13 -0
  19. package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
  20. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  21. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  22. package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
  23. package/dist/types/client/state/boards-store.d.ts +79 -0
  24. package/dist/types/client/state/canvas-store.d.ts +2 -0
  25. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  26. package/dist/types/client/state/presentation.d.ts +3 -0
  27. package/dist/types/client/state/session-store.d.ts +7 -0
  28. package/dist/types/client/types.d.ts +2 -2
  29. package/dist/types/json-render/catalog.d.ts +3 -3
  30. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  31. package/dist/types/json-render/server.d.ts +8 -0
  32. package/dist/types/mcp/canvas-access.d.ts +10 -3
  33. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  34. package/dist/types/server/agent-presence.d.ts +1 -1
  35. package/dist/types/server/attribution.d.ts +11 -0
  36. package/dist/types/server/ax-context.d.ts +3 -1
  37. package/dist/types/server/ax-interaction.d.ts +2 -2
  38. package/dist/types/server/ax-state.d.ts +13 -3
  39. package/dist/types/server/backup.d.ts +36 -0
  40. package/dist/types/server/board-export.d.ts +45 -0
  41. package/dist/types/server/canvas-db.d.ts +65 -9
  42. package/dist/types/server/canvas-operations.d.ts +45 -0
  43. package/dist/types/server/canvas-provenance.d.ts +1 -1
  44. package/dist/types/server/canvas-schema.d.ts +1 -0
  45. package/dist/types/server/canvas-state.d.ts +146 -3
  46. package/dist/types/server/canvas-validation.d.ts +3 -0
  47. package/dist/types/server/context-brief.d.ts +111 -0
  48. package/dist/types/server/context-reads.d.ts +55 -0
  49. package/dist/types/server/document-import.d.ts +66 -0
  50. package/dist/types/server/html-surface.d.ts +1 -1
  51. package/dist/types/server/index.d.ts +107 -4
  52. package/dist/types/server/mutation-history.d.ts +1 -0
  53. package/dist/types/server/operations/invoker.d.ts +2 -0
  54. package/dist/types/server/operations/mcp.d.ts +7 -1
  55. package/dist/types/server/operations/ops/app.d.ts +2 -0
  56. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  57. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  58. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  59. package/dist/types/server/operations/ops/export.d.ts +2 -0
  60. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  61. package/dist/types/server/operations/ops/json-render.d.ts +2 -0
  62. package/dist/types/server/operations/ops/nodes.d.ts +4 -2
  63. package/dist/types/server/operations/ops/query.d.ts +2 -0
  64. package/dist/types/server/operations/registry.d.ts +9 -1
  65. package/dist/types/server/operations/types.d.ts +1 -1
  66. package/dist/types/server/server.d.ts +1 -0
  67. package/dist/types/server/trace-manager.d.ts +7 -0
  68. package/dist/types/server/web-artifacts.d.ts +3 -0
  69. package/dist/types/server/webpage-node.d.ts +22 -1
  70. package/dist/types/server/workbench-auth.d.ts +3 -0
  71. package/dist/types/shared/boards.d.ts +6 -0
  72. package/dist/types/shared/tour.d.ts +72 -0
  73. package/docs/RELEASE.md +5 -0
  74. package/docs/cli.md +157 -0
  75. package/docs/http-api.md +277 -6
  76. package/docs/mcp.md +96 -7
  77. package/docs/node-types.md +106 -0
  78. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  79. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  80. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  81. package/docs/plans/plan-013-static-board-export.md +47 -0
  82. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  83. package/docs/product-vision-2026-09.md +118 -18
  84. package/docs/sdk.md +50 -0
  85. package/package.json +1 -1
  86. package/skills/pmx-canvas/SKILL.md +62 -5
  87. package/skills/pmx-canvas/references/full-reference.md +44 -8
  88. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  89. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  90. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  91. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  92. package/src/cli/agent.ts +9 -0
  93. package/src/cli/commands/ax.ts +13 -0
  94. package/src/cli/commands/backup.ts +66 -0
  95. package/src/cli/commands/boards.ts +71 -0
  96. package/src/cli/commands/export.ts +30 -0
  97. package/src/cli/commands/nodes.ts +14 -1
  98. package/src/cli/commands/pins.ts +6 -2
  99. package/src/cli/commands/pump.ts +3 -0
  100. package/src/cli/commands/query.ts +33 -7
  101. package/src/cli/commands/record.ts +218 -0
  102. package/src/cli/commands/view.ts +7 -5
  103. package/src/cli/index.ts +21 -9
  104. package/src/client/App.tsx +28 -4
  105. package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
  106. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  107. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  108. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  109. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  110. package/src/client/canvas/CanvasNode.tsx +8 -0
  111. package/src/client/canvas/CanvasViewport.tsx +3 -0
  112. package/src/client/canvas/CommandPalette.tsx +107 -4
  113. package/src/client/canvas/ContextMenu.tsx +4 -0
  114. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  115. package/src/client/canvas/ExportDialog.tsx +197 -0
  116. package/src/client/canvas/HomeView.tsx +277 -0
  117. package/src/client/canvas/IntentLayer.tsx +9 -2
  118. package/src/client/canvas/Presentation.tsx +108 -0
  119. package/src/client/canvas/SessionPanel.tsx +28 -1
  120. package/src/client/canvas/ToolRail.tsx +38 -3
  121. package/src/client/canvas/TopBar.tsx +49 -7
  122. package/src/client/canvas/import-files.ts +19 -1
  123. package/src/client/canvas/kind-colors.ts +1 -0
  124. package/src/client/canvas/use-node-drag.ts +8 -3
  125. package/src/client/icons.tsx +2 -0
  126. package/src/client/mermaid-entry.ts +39 -0
  127. package/src/client/nodes/AttachmentNode.tsx +148 -0
  128. package/src/client/nodes/BoardNode.tsx +35 -0
  129. package/src/client/nodes/FileNode.tsx +6 -0
  130. package/src/client/nodes/HtmlNode.tsx +12 -13
  131. package/src/client/nodes/MarkdownNode.tsx +47 -19
  132. package/src/client/nodes/McpAppNode.tsx +105 -13
  133. package/src/client/nodes/MermaidNode.tsx +3 -2
  134. package/src/client/nodes/ax-node-actions.ts +2 -5
  135. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  136. package/src/client/state/boards-store.ts +167 -0
  137. package/src/client/state/canvas-store.ts +22 -0
  138. package/src/client/state/intent-bridge.ts +30 -2
  139. package/src/client/state/intent-store.ts +5 -1
  140. package/src/client/state/node-factory.ts +1 -0
  141. package/src/client/state/presentation.ts +7 -0
  142. package/src/client/state/session-store.ts +8 -0
  143. package/src/client/state/sse-bridge.ts +52 -5
  144. package/src/client/theme/global.css +498 -28
  145. package/src/client/types.ts +2 -0
  146. package/src/json-render/server.ts +45 -9
  147. package/src/mcp/canvas-access.ts +59 -22
  148. package/src/mcp/context-uri-template.ts +28 -0
  149. package/src/mcp/server.ts +122 -15
  150. package/src/server/agent-context.ts +116 -29
  151. package/src/server/agent-presence.ts +15 -9
  152. package/src/server/attribution.ts +29 -0
  153. package/src/server/ax-context.ts +4 -2
  154. package/src/server/ax-interaction.ts +1 -0
  155. package/src/server/ax-state-manager.ts +72 -3
  156. package/src/server/ax-state.ts +41 -0
  157. package/src/server/backup.ts +178 -0
  158. package/src/server/board-export.ts +686 -0
  159. package/src/server/canvas-db.ts +612 -105
  160. package/src/server/canvas-operations.ts +183 -31
  161. package/src/server/canvas-provenance.ts +1 -0
  162. package/src/server/canvas-schema.ts +22 -0
  163. package/src/server/canvas-state.ts +812 -48
  164. package/src/server/canvas-validation.ts +9 -1
  165. package/src/server/context-brief.ts +372 -0
  166. package/src/server/context-reads.ts +238 -0
  167. package/src/server/demo-state.json +670 -9
  168. package/src/server/document-import.ts +223 -0
  169. package/src/server/html-primitives.ts +199 -91
  170. package/src/server/html-surface.ts +8 -3
  171. package/src/server/index.ts +243 -5
  172. package/src/server/mutation-history.ts +10 -13
  173. package/src/server/operations/composites.ts +29 -5
  174. package/src/server/operations/http.ts +9 -3
  175. package/src/server/operations/index.ts +8 -0
  176. package/src/server/operations/invoker.ts +14 -3
  177. package/src/server/operations/mcp.ts +50 -11
  178. package/src/server/operations/ops/app.ts +15 -0
  179. package/src/server/operations/ops/ax-read.ts +10 -6
  180. package/src/server/operations/ops/ax-timeline.ts +75 -0
  181. package/src/server/operations/ops/backup.ts +134 -0
  182. package/src/server/operations/ops/batch.ts +20 -4
  183. package/src/server/operations/ops/boards.ts +351 -0
  184. package/src/server/operations/ops/export.ts +74 -0
  185. package/src/server/operations/ops/ext-app.ts +4 -0
  186. package/src/server/operations/ops/groups.ts +14 -7
  187. package/src/server/operations/ops/imports.ts +220 -0
  188. package/src/server/operations/ops/json-render.ts +37 -5
  189. package/src/server/operations/ops/nodes.ts +99 -21
  190. package/src/server/operations/ops/query.ts +206 -9
  191. package/src/server/operations/ops/viewport.ts +32 -0
  192. package/src/server/operations/registry.ts +129 -31
  193. package/src/server/operations/types.ts +1 -1
  194. package/src/server/scope-fence.ts +1 -1
  195. package/src/server/server.ts +155 -8
  196. package/src/server/spatial-analysis.ts +9 -17
  197. package/src/server/trace-manager.ts +19 -0
  198. package/src/server/web-artifacts.ts +11 -3
  199. package/src/server/webpage-node.ts +173 -13
  200. package/src/server/workbench-auth.ts +11 -0
  201. package/src/server/workboard.ts +3 -3
  202. package/src/shared/boards.ts +18 -0
  203. package/src/shared/node-sizes.ts +1 -0
  204. package/src/shared/tour.ts +87 -0
@@ -12,9 +12,19 @@
12
12
  * the one-shot boot migration into SQLite was retired.
13
13
  */
14
14
 
15
- import { createHash } from 'node:crypto';
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
17
- import { isAbsolute, join, dirname, relative } from 'node:path';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
+ import { tourSchema, type Tour } from '../shared/tour.js';
17
+ import {
18
+ copyFileSync,
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ rmSync,
25
+ unlinkSync,
26
+ } from 'node:fs';
27
+ import { basename, isAbsolute, join, dirname, relative } from 'node:path';
18
28
  import { gzipSync, gunzipSync } from 'node:zlib';
19
29
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
20
30
  import {
@@ -28,13 +38,41 @@ import {
28
38
  renameSnapshotInDB,
29
39
  writeBlobToDB,
30
40
  readBlobFromDB,
31
- isDbPopulated,
32
41
  checkpointCanvasDb,
42
+ createBoardInDB,
43
+ createBoardWithStateInDB,
44
+ readContextBriefCursor,
45
+ advanceContextBriefCursor,
46
+ deleteBoardFromDB,
47
+ getActiveBoardIdFromDB,
48
+ getBoardFromDB,
49
+ listBoardsFromDB,
50
+ readMetaFromDB,
51
+ readThemeFromDB,
52
+ updateBoardInDB,
53
+ writeMetaToDB,
54
+ saveThemeToDB,
55
+ setActiveBoardIdInDB,
56
+ type CanvasBoard,
33
57
  finalizeCanvasDbForClose,
34
58
  type PersistedCanvasState,
35
59
  type CanvasTheme,
36
60
  type AxTimelineQuery,
37
61
  } from './canvas-db.js';
62
+ import {
63
+ copyBoardAttachments,
64
+ createImport,
65
+ getAttachment,
66
+ getImport,
67
+ listImports,
68
+ readAttachmentBytes,
69
+ storeAttachment,
70
+ updateImport,
71
+ type DocumentImport,
72
+ type DocumentImportStatus,
73
+ type ImportSection,
74
+ type Attachment,
75
+ } from './document-import.js';
38
76
  import { normalizeCanvasTheme } from './canvas-db.js';
39
77
  import {
40
78
  type CanvasPlacementRect,
@@ -73,6 +111,14 @@ import {
73
111
  type PmxAxTimelineSummary,
74
112
  } from './ax-state.js';
75
113
  import { AxStateManager } from './ax-state-manager.js';
114
+ import { currentActor, unknownActor, type ActorAttribution } from './attribution.js';
115
+ import {
116
+ appendContextReadToDB,
117
+ loadContextReadsFromDB,
118
+ type ContextRead,
119
+ type ContextReadConsumerSummary,
120
+ type ContextReadInput,
121
+ } from './context-reads.js';
76
122
 
77
123
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
78
124
  console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
@@ -169,6 +215,7 @@ export interface CanvasNodeState {
169
215
  | 'webpage'
170
216
  | 'json-render'
171
217
  | 'graph'
218
+ | 'board'
172
219
  | 'prompt'
173
220
  | 'response'
174
221
  | 'status'
@@ -187,6 +234,30 @@ export interface CanvasNodeState {
187
234
  collapsed: boolean;
188
235
  pinned: boolean;
189
236
  data: Record<string, unknown>;
237
+ /** Server-owned provenance. Legacy rows normalize to unknown. */
238
+ createdBy?: ActorAttribution;
239
+ lastEditedBy?: ActorAttribution;
240
+ /** Board-monotonic revision of this node's latest semantic content. */
241
+ contentRevision?: number;
242
+ }
243
+
244
+ export interface NodeDeletionTombstone {
245
+ nodeId: string;
246
+ revision: number;
247
+ deletedBy: ActorAttribution;
248
+ }
249
+
250
+ export interface BoardContentRevision {
251
+ revision: number;
252
+ /** Cursors below this value are expired and must perform a full reset. */
253
+ retentionFloor: number;
254
+ }
255
+
256
+ export interface BoardContentDelta extends BoardContentRevision {
257
+ since: number;
258
+ reset: boolean;
259
+ nodes: CanvasNodeState[];
260
+ deleted: NodeDeletionTombstone[];
190
261
  }
191
262
 
192
263
  export interface ViewportState {
@@ -223,6 +294,7 @@ export interface CanvasAnnotation {
223
294
  }
224
295
 
225
296
  export interface CanvasLayout {
297
+ tour?: Tour;
226
298
  viewport: ViewportState;
227
299
  theme: CanvasTheme;
228
300
  nodes: CanvasNodeState[];
@@ -239,6 +311,11 @@ export interface CanvasNodeUpdate {
239
311
 
240
312
  export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
241
313
 
314
+ /** Name for a board nobody named yet: when it was made, to the minute. */
315
+ export function defaultBoardName(): string {
316
+ return `Board ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
317
+ }
318
+
242
319
  export interface MutationRecordInfo {
243
320
  operationType:
244
321
  | 'addNode'
@@ -333,9 +410,13 @@ class CanvasStateManager {
333
410
  private edges = new Map<string, CanvasEdge>();
334
411
  private annotations = new Map<string, CanvasAnnotation>();
335
412
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
413
+ private _tour: Tour | undefined;
336
414
  private _theme: CanvasTheme = 'dark';
337
415
  private _contextPinnedNodeIds = new Set<string>();
338
416
  private _workspaceRoot = process.cwd();
417
+ private _contentRevision = 0;
418
+ private _revisionFloor = 0;
419
+ private _deletionTombstones: NodeDeletionTombstone[] = [];
339
420
 
340
421
  // ── AX state (canvas-bound + timeline + host partitions) ──────────
341
422
  // Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
@@ -503,12 +584,58 @@ class CanvasStateManager {
503
584
  }
504
585
 
505
586
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
587
+ const data = { ...node.data };
588
+ delete data.createdBy;
589
+ delete data.lastEditedBy;
590
+ delete data.contentRevision;
506
591
  return {
507
592
  ...node,
508
- data: normalizeCanvasNodeData(node.type, node.data),
593
+ data: normalizeCanvasNodeData(node.type, data),
594
+ createdBy: node.createdBy ?? unknownActor(),
595
+ lastEditedBy: node.lastEditedBy ?? unknownActor(),
596
+ contentRevision: Number.isSafeInteger(node.contentRevision) ? node.contentRevision : 0,
597
+ };
598
+ }
599
+
600
+ private nextContentRevision(): number {
601
+ this._contentRevision += 1;
602
+ return this._contentRevision;
603
+ }
604
+
605
+ private revisionState() {
606
+ return {
607
+ revision: this._contentRevision,
608
+ floor: this._revisionFloor,
609
+ tombstones: structuredClone(this._deletionTombstones),
610
+ };
611
+ }
612
+
613
+ /**
614
+ * Stable delta cursor contract: revisions strictly increase per active board;
615
+ * geometry-only writes do not advance them. `reset` means `since` predates
616
+ * retained tombstones, so callers must replace their view with `nodes`.
617
+ */
618
+ readContentDelta(since = this._contentRevision): BoardContentDelta {
619
+ const cursor = Math.max(0, Math.floor(since));
620
+ const reset = cursor < this._revisionFloor;
621
+ return {
622
+ since: cursor,
623
+ revision: this._contentRevision,
624
+ retentionFloor: this._revisionFloor,
625
+ reset,
626
+ nodes: Array.from(this.nodes.values())
627
+ .filter((node) => reset || (node.contentRevision ?? 0) > cursor)
628
+ .map((node) => structuredClone(this.nodeForRead(node))),
629
+ deleted: reset
630
+ ? []
631
+ : this._deletionTombstones.filter((item) => item.revision > cursor).map((item) => structuredClone(item)),
509
632
  };
510
633
  }
511
634
 
635
+ getContentRevision(): BoardContentRevision {
636
+ return { revision: this._contentRevision, retentionFloor: this._revisionFloor };
637
+ }
638
+
512
639
  private nodeForRead(node: CanvasNodeState): CanvasNodeState {
513
640
  const resolved = this.resolveNodeDataBlobs(node);
514
641
  return {
@@ -637,6 +764,7 @@ class CanvasStateManager {
637
764
 
638
765
  // ── Persistence ────────────────────────────────────────────
639
766
  private _db: import('bun:sqlite').Database | null = null;
767
+ private _dbPath: string | null = null;
640
768
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
641
769
 
642
770
  /** Workspace root backing persistence and workspace-relative path resolution. */
@@ -674,7 +802,8 @@ class CanvasStateManager {
674
802
  // 0.4.0 — the one-shot boot migration that copied them into SQLite and
675
803
  // renamed the originals to `.bak` was retired.
676
804
  try {
677
- this._db = openCanvasDb(dbPath);
805
+ this._db = openCanvasDb(dbPath, { migratedBoardName: basename(workspaceRoot) || 'Board' });
806
+ this._dbPath = dbPath;
678
807
  } catch (error) {
679
808
  logCanvasStateWarning('open canvas database failed', error, { dbPath });
680
809
  }
@@ -786,7 +915,7 @@ class CanvasStateManager {
786
915
  }
787
916
 
788
917
  private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
789
- if (node.type !== 'mcp-app') return node;
918
+ if (node.type !== 'mcp-app' && node.type !== 'html') return node;
790
919
  let changed = false;
791
920
  const data = { ...node.data };
792
921
  for (const [key, value] of Object.entries(data)) {
@@ -800,7 +929,7 @@ class CanvasStateManager {
800
929
  }
801
930
 
802
931
  private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
803
- if (node.type !== 'mcp-app') return node;
932
+ if (node.type !== 'mcp-app' && node.type !== 'html') return node;
804
933
  let changed = false;
805
934
  const data = { ...node.data };
806
935
  for (const [key, value] of Object.entries(data)) {
@@ -840,22 +969,27 @@ class CanvasStateManager {
840
969
  annotations: [],
841
970
  contextPins: [],
842
971
  ax: createEmptyAxState(),
972
+ revisionState: { revision: 0, floor: 0, tombstones: [] },
843
973
  };
844
974
  }
845
975
 
846
- /** Load canvas state from SQLite. Call once on server startup. */
976
+ /**
977
+ * Load the board that was open last. Call once on server startup. With no
978
+ * such board the workspace starts on Home (no board open).
979
+ */
847
980
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
848
981
  // Host capability lives in its own table (not snapshotted / not in PmxAxState).
849
982
  this.ax.loadHostCapabilityFromDb();
850
- // Only a populated DB counts as saved state (legacy `state.json` loading was
851
- // retired with the 0.4.0 boot-migration removal).
852
- if (this._db && isDbPopulated(this._db)) {
983
+ if (this._db) {
853
984
  try {
854
- const state = loadStateFromDB(this._db);
855
- if (state) {
985
+ const boardId = getActiveBoardIdFromDB(this._db);
986
+ const state = boardId ? loadStateFromDB(this._db, boardId) : null;
987
+ if (boardId && state) {
856
988
  this.applyPersistedState(state);
989
+ this._activeBoardId = boardId;
857
990
  return true;
858
991
  }
992
+ this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
859
993
  } catch (error) {
860
994
  logCanvasStateWarning('load state from sqlite failed', error, {});
861
995
  }
@@ -867,6 +1001,434 @@ class CanvasStateManager {
867
1001
  return false;
868
1002
  }
869
1003
 
1004
+ // ── Library file (backup / restore) ─────────────────────────
1005
+
1006
+ /** The SQLite file holding every board, or null without a workspace database. */
1007
+ get databasePath(): string | null {
1008
+ return this._db ? this._dbPath : null;
1009
+ }
1010
+
1011
+ readWorkspaceMeta(key: string): string | null {
1012
+ return this._db ? readMetaFromDB(this._db, key) : null;
1013
+ }
1014
+
1015
+ writeWorkspaceMeta(key: string, value: string | null): void {
1016
+ if (this._db) writeMetaToDB(this._db, key, value);
1017
+ }
1018
+
1019
+ /** A consistent copy of the whole library, safe while the server runs. */
1020
+ backupDatabaseTo(path: string): void {
1021
+ if (!this._db) throw new Error('No workspace database to back up.');
1022
+ this.flushToDisk();
1023
+ this._db.run('VACUUM INTO ?', [path]);
1024
+ }
1025
+
1026
+ /**
1027
+ * Replace the library with `file`: the current file is kept beside it as
1028
+ * `<db>.before-restore`, then the restored file opens (migrating an older
1029
+ * layout) on the board it had open last.
1030
+ */
1031
+ replaceDatabase(file: string): void {
1032
+ const dbPath = this.databasePath;
1033
+ if (!dbPath) throw new Error('No workspace database to restore into.');
1034
+ const root = this._workspaceRoot;
1035
+ const recoveryPath = `${dbPath}.before-restore`;
1036
+ const stagedPath = `${dbPath}.restore-${process.pid}-${Date.now()}`;
1037
+ this.flushToDisk();
1038
+ copyFileSync(file, stagedPath);
1039
+ let closed = false;
1040
+ let recoveryReady = false;
1041
+ try {
1042
+ this.close();
1043
+ closed = true;
1044
+ copyFileSync(dbPath, recoveryPath);
1045
+ recoveryReady = true;
1046
+ for (const suffix of ['-wal', '-shm']) rmSync(`${dbPath}${suffix}`, { force: true });
1047
+ copyFileSync(stagedPath, dbPath);
1048
+ this.setWorkspaceRoot(root);
1049
+ if (!this._db) throw new Error('Could not open the restored database.');
1050
+ const expectedBoard = getActiveBoardIdFromDB(this._db);
1051
+ const loaded = this.loadFromDisk({ clearExisting: true });
1052
+ if (expectedBoard && !loaded) throw new Error('Could not load the restored board.');
1053
+ } catch (error) {
1054
+ if (closed) {
1055
+ this.close();
1056
+ if (recoveryReady) {
1057
+ for (const suffix of ['-wal', '-shm']) rmSync(`${dbPath}${suffix}`, { force: true });
1058
+ copyFileSync(recoveryPath, dbPath);
1059
+ }
1060
+ this.setWorkspaceRoot(root);
1061
+ this.loadFromDisk({ clearExisting: true });
1062
+ }
1063
+ throw error;
1064
+ } finally {
1065
+ rmSync(stagedPath, { force: true });
1066
+ }
1067
+ this.notifyChange('nodes');
1068
+ this.notifyChange('pins');
1069
+ this.notifyChange('ax');
1070
+ }
1071
+
1072
+ // ── Boards ──────────────────────────────────────────────────
1073
+
1074
+ private _activeBoardId: string | null = null;
1075
+ private _boardGeneration = 0;
1076
+
1077
+ /** Invalidates asynchronous work whenever the loaded board state is replaced. */
1078
+ get boardGeneration(): number {
1079
+ return this._boardGeneration;
1080
+ }
1081
+
1082
+ /** The open board, or null on Home. */
1083
+ get activeBoardId(): string | null {
1084
+ return this._activeBoardId;
1085
+ }
1086
+
1087
+ getActiveBoard(): CanvasBoard | null {
1088
+ if (!this._db || !this._activeBoardId) return null;
1089
+ const board = getBoardFromDB(this._db, this._activeBoardId);
1090
+ return board ? { ...board, nodeCount: this.nodes.size } : null;
1091
+ }
1092
+
1093
+ /**
1094
+ * One board's content without opening it: the open board from memory, any
1095
+ * other straight from SQLite. Null when there is no such board.
1096
+ */
1097
+ readBoard(
1098
+ id: string,
1099
+ includeBlobs = true,
1100
+ ): { board: CanvasBoard; state: PersistedCanvasState; layout: CanvasLayout } | null {
1101
+ if (!this._db) return null;
1102
+ const board = this.listBoards().find((entry) => entry.id === id);
1103
+ if (!board) return null;
1104
+ if (id === this._activeBoardId) {
1105
+ const layout = includeBlobs ? this.getLayout() : this.getLayoutForPersistence();
1106
+ if (!includeBlobs)
1107
+ layout.nodes = layout.nodes.map((node) => ({
1108
+ ...node,
1109
+ pinned: node.pinned || this._contextPinnedNodeIds.has(node.id),
1110
+ }));
1111
+ return {
1112
+ board,
1113
+ layout,
1114
+ state: {
1115
+ version: 1,
1116
+ theme: this._theme,
1117
+ tour: layout.tour,
1118
+ ax: this.getAxState(),
1119
+ viewport: layout.viewport,
1120
+ nodes: layout.nodes,
1121
+ edges: layout.edges,
1122
+ annotations: layout.annotations,
1123
+ contextPins: Array.from(this._contextPinnedNodeIds),
1124
+ revisionState: this.revisionState(),
1125
+ },
1126
+ };
1127
+ }
1128
+ const state = loadStateFromDB(this._db, id);
1129
+ if (!state) return null;
1130
+ const pins = new Set(state.contextPins);
1131
+ state.nodes = state.nodes.map((node) => ({
1132
+ ...(includeBlobs ? this.resolveNodeDataBlobs(node) : node),
1133
+ pinned: node.pinned || pins.has(node.id),
1134
+ }));
1135
+ return {
1136
+ board,
1137
+ state,
1138
+ layout: {
1139
+ tour: state.tour,
1140
+ theme: state.theme ?? this._theme,
1141
+ viewport: state.viewport,
1142
+ nodes: state.nodes,
1143
+ edges: state.edges,
1144
+ annotations: state.annotations ?? [],
1145
+ },
1146
+ };
1147
+ }
1148
+
1149
+ /** Every board in the workspace, most recently opened first. */
1150
+ listBoards(): CanvasBoard[] {
1151
+ if (!this._db) return [];
1152
+ return listBoardsFromDB(this._db).map((board) =>
1153
+ board.id === this._activeBoardId ? { ...board, nodeCount: this.nodes.size } : board,
1154
+ );
1155
+ }
1156
+
1157
+ storeAttachment(input: { boardId: string; name: string; mime: string; bytes: Uint8Array }): Attachment | null {
1158
+ if (!this._db) return null;
1159
+ if (input.boardId === this._activeBoardId) this.flushToDisk();
1160
+ if (!getBoardFromDB(this._db, input.boardId)) return null;
1161
+ return storeAttachment(this._db, input);
1162
+ }
1163
+ getAttachment(id: string): Attachment | null {
1164
+ return this._db ? getAttachment(this._db, id) : null;
1165
+ }
1166
+ readAttachmentBytes(id: string): Uint8Array | null {
1167
+ return this._db ? readAttachmentBytes(this._db, id) : null;
1168
+ }
1169
+ createDocumentImport(attachmentId: string, position: { x: number; y: number }): DocumentImport | null {
1170
+ return this._db ? createImport(this._db, attachmentId, position) : null;
1171
+ }
1172
+ getDocumentImport(id: string): DocumentImport | null {
1173
+ return this._db ? getImport(this._db, id) : null;
1174
+ }
1175
+ listDocumentImports(boardId?: string): DocumentImport[] {
1176
+ return this._db ? listImports(this._db, boardId) : [];
1177
+ }
1178
+ updateDocumentImport(
1179
+ id: string,
1180
+ status: DocumentImportStatus,
1181
+ fields?: {
1182
+ sections?: ImportSection[];
1183
+ warnings?: string[];
1184
+ agentDescription?: string;
1185
+ reason?: string;
1186
+ committedNodeIds?: string[];
1187
+ },
1188
+ ): DocumentImport | null {
1189
+ return this._db ? updateImport(this._db, id, status, fields) : null;
1190
+ }
1191
+ commitDocumentImport(id: string, nodeIds: string[]): DocumentImport | null {
1192
+ if (!this._db) return null;
1193
+ if (this._saveTimer) {
1194
+ clearTimeout(this._saveTimer);
1195
+ this._saveTimer = null;
1196
+ }
1197
+ return this._db.transaction(() => {
1198
+ if (!this.saveToDisk()) throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
1199
+ return updateImport(this._db!, id, 'committed', { committedNodeIds: nodeIds });
1200
+ })();
1201
+ }
1202
+
1203
+ getContextBriefCursor(boardId: string, consumer: string): number | null {
1204
+ return this._db ? readContextBriefCursor(this._db, boardId, consumer) : null;
1205
+ }
1206
+
1207
+ advanceContextBriefCursor(boardId: string, consumer: string, revision: number): void {
1208
+ // A durable cursor must never get ahead of the board content it describes.
1209
+ if (boardId === this._activeBoardId) this.flushToDisk();
1210
+ if (this._db) advanceContextBriefCursor(this._db, boardId, consumer, revision);
1211
+ }
1212
+
1213
+ /** Creates a board without opening it. */
1214
+ createBoard(name: string, category: string | null = null): CanvasBoard | null {
1215
+ if (!this._db) return null;
1216
+ return createBoardInDB(this._db, name, category);
1217
+ }
1218
+
1219
+ updateBoard(id: string, patch: { name?: string; category?: string | null }): boolean {
1220
+ return this._db ? updateBoardInDB(this._db, id, patch) : false;
1221
+ }
1222
+
1223
+ setBoardReadme(id: string, nodeId: string | null): boolean {
1224
+ if (!this._db) return false;
1225
+ const read = this.readBoard(id, false);
1226
+ if (!read) return false;
1227
+ if (nodeId && !read.layout.nodes.some((node) => node.id === nodeId && node.type === 'markdown')) return false;
1228
+ return updateBoardInDB(this._db, id, { readmeNodeId: nodeId });
1229
+ }
1230
+
1231
+ /** Bounded inactive-board creation; never replaces the state the human has open. */
1232
+ createBoardFromBoard(input: {
1233
+ sourceBoardId: string;
1234
+ name: string;
1235
+ category?: string | null;
1236
+ nodeIds?: string[];
1237
+ includeReadme?: boolean;
1238
+ includeStructure?: boolean;
1239
+ }): CanvasBoard | null {
1240
+ if (!this._db) return null;
1241
+ const source = this.readBoard(input.sourceBoardId, true);
1242
+ if (!source) return null;
1243
+ const selected = new Set(input.nodeIds ?? []);
1244
+ if (input.includeReadme && source.board.readmeNodeId) selected.add(source.board.readmeNodeId);
1245
+ if (input.includeStructure) {
1246
+ for (const node of source.layout.nodes) {
1247
+ if (node.type === 'group') selected.add(node.id);
1248
+ }
1249
+ }
1250
+ const copied = source.layout.nodes.filter(
1251
+ (node) => selected.has(node.id) && !['prompt', 'response', 'trace', 'mcp-app'].includes(node.type),
1252
+ );
1253
+ const ids = new Map(copied.map((node) => [node.id, randomUUID()]));
1254
+ const referenceKeys = new Set([
1255
+ 'nodeId',
1256
+ 'nodeIds',
1257
+ 'sourceNodeId',
1258
+ 'targetNodeId',
1259
+ 'embeddedNodeId',
1260
+ 'embeddedNodeIds',
1261
+ 'embeddedGraphId',
1262
+ ]);
1263
+ const remap = (value: unknown, key = ''): unknown => {
1264
+ if (typeof value === 'string') return referenceKeys.has(key) ? (ids.get(value) ?? null) : value;
1265
+ if (Array.isArray(value)) return value.map((item) => remap(item, key)).filter((item) => item !== null);
1266
+ if (value && typeof value === 'object') {
1267
+ return Object.fromEntries(
1268
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, remap(item, key)]),
1269
+ );
1270
+ }
1271
+ return value;
1272
+ };
1273
+ const actor = currentActor();
1274
+ const nodes: CanvasNodeState[] = copied.map((node, index) => {
1275
+ const data = remap(node.data) as Record<string, unknown>;
1276
+ if (node.type === 'graph' || node.type === 'json-render') {
1277
+ data.url = `/api/canvas/json-render/view?nodeId=${encodeURIComponent(ids.get(node.id)!)}`;
1278
+ }
1279
+ delete data.axStep;
1280
+ delete data.axWorkStatus;
1281
+ delete data.workItemId;
1282
+ delete data.approvalGateId;
1283
+ delete data.parentGroup;
1284
+ if (typeof node.data.parentGroup === 'string' && ids.has(node.data.parentGroup)) {
1285
+ data.parentGroup = ids.get(node.data.parentGroup);
1286
+ }
1287
+ if (node.type === 'group')
1288
+ data.children = Array.isArray(node.data.children)
1289
+ ? node.data.children.flatMap((id) => (typeof id === 'string' && ids.has(id) ? [ids.get(id)!] : []))
1290
+ : [];
1291
+ return {
1292
+ ...node,
1293
+ id: ids.get(node.id)!,
1294
+ pinned: false,
1295
+ data,
1296
+ createdBy: actor,
1297
+ lastEditedBy: actor,
1298
+ contentRevision: index + 1,
1299
+ };
1300
+ });
1301
+ nodes.push({
1302
+ id: randomUUID(),
1303
+ type: 'board' as const,
1304
+ position: { x: 40, y: Math.max(40, ...nodes.map((node) => node.position.y + node.size.height + 40)) },
1305
+ size: { width: 360, height: 160 },
1306
+ zIndex: 1,
1307
+ collapsed: false,
1308
+ pinned: false,
1309
+ data: { title: source.board.name, boardId: source.board.id },
1310
+ createdBy: actor,
1311
+ lastEditedBy: actor,
1312
+ contentRevision: nodes.length + 1,
1313
+ });
1314
+ const edges = source.layout.edges
1315
+ .filter((edge) => ids.has(edge.from) && ids.has(edge.to))
1316
+ .map((edge) => ({ ...edge, id: randomUUID(), from: ids.get(edge.from)!, to: ids.get(edge.to)! }));
1317
+ const state: PersistedCanvasState = {
1318
+ version: 1,
1319
+ theme: source.state.theme,
1320
+ viewport: { x: 0, y: 0, scale: 1 },
1321
+ nodes,
1322
+ edges,
1323
+ annotations: [],
1324
+ contextPins: [],
1325
+ ax: createEmptyAxState(),
1326
+ revisionState: { revision: nodes.length, floor: 0, tombstones: [] },
1327
+ };
1328
+ const board = createBoardWithStateInDB(
1329
+ this._db,
1330
+ input.name,
1331
+ input.category ?? null,
1332
+ this.externalizePersistedStateBlobs(state),
1333
+ input.includeReadme && source.board.readmeNodeId ? (ids.get(source.board.readmeNodeId) ?? null) : null,
1334
+ );
1335
+ const referencedAttachmentIds = new Set<string>();
1336
+ for (const node of nodes) {
1337
+ if (typeof node.data.attachmentId === 'string') referencedAttachmentIds.add(node.data.attachmentId);
1338
+ const provenance = node.data.source;
1339
+ if (provenance && typeof provenance === 'object') {
1340
+ const sourceAttachmentId = (provenance as Record<string, unknown>).attachmentId;
1341
+ if (typeof sourceAttachmentId === 'string') referencedAttachmentIds.add(sourceAttachmentId);
1342
+ }
1343
+ delete node.data.importId;
1344
+ }
1345
+ const attachmentIds = copyBoardAttachments(this._db, source.board.id, board.id, referencedAttachmentIds);
1346
+ if (attachmentIds.size > 0) {
1347
+ const createdImportAttachments = new Set<string>();
1348
+ for (const row of this._db
1349
+ .query<{ id: string; data: string }, [string]>('SELECT id,data FROM nodes WHERE board_id=?')
1350
+ .all(board.id)) {
1351
+ const data = JSON.parse(row.data) as Record<string, unknown>;
1352
+ const old = typeof data.attachmentId === 'string' ? data.attachmentId : null;
1353
+ const sourceData =
1354
+ data.source && typeof data.source === 'object' ? (data.source as Record<string, unknown>) : null;
1355
+ const sourceOld = typeof sourceData?.attachmentId === 'string' ? sourceData.attachmentId : null;
1356
+ if (old && attachmentIds.has(old)) data.attachmentId = attachmentIds.get(old);
1357
+ if (sourceOld && attachmentIds.has(sourceOld))
1358
+ data.source = { ...sourceData, attachmentId: attachmentIds.get(sourceOld) };
1359
+ delete data.importId;
1360
+ this._db.run('UPDATE nodes SET data=? WHERE board_id=? AND id=?', [JSON.stringify(data), board.id, row.id]);
1361
+ if (old && attachmentIds.has(old) && !createdImportAttachments.has(old)) {
1362
+ createImport(this._db, attachmentIds.get(old)!, { x: 40, y: 260 });
1363
+ createdImportAttachments.add(old);
1364
+ }
1365
+ }
1366
+ }
1367
+ return board;
1368
+ }
1369
+
1370
+ /** Deletes a board and its snapshots; deleting the open board returns to Home first. */
1371
+ deleteBoard(id: string): boolean {
1372
+ if (!this._db) return false;
1373
+ if (id === this._activeBoardId && !this.switchBoard(null)) return false;
1374
+ return deleteBoardFromDB(this._db, id);
1375
+ }
1376
+
1377
+ /**
1378
+ * Save the open board, then open `id` (or Home for null). The canvas state is
1379
+ * replaced wholesale; callers own the runtime side (undo history, app
1380
+ * sessions, watchers, SSE) — see `openCanvasBoard` in canvas-operations.
1381
+ */
1382
+ switchBoard(id: string | null): boolean {
1383
+ if (!this._db) return false;
1384
+ if (this._saveTimer) {
1385
+ clearTimeout(this._saveTimer);
1386
+ this._saveTimer = null;
1387
+ }
1388
+ if (!this.saveToDisk()) {
1389
+ throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state before switching boards.');
1390
+ }
1391
+ let next = this.emptyPersistedState();
1392
+ if (id) {
1393
+ const state = loadStateFromDB(this._db, id);
1394
+ if (!state) return false;
1395
+ next = state;
1396
+ }
1397
+ this.applyPersistedState(next);
1398
+ this._activeBoardId = id;
1399
+ setActiveBoardIdInDB(this._db, id);
1400
+ this.notifyChange('nodes');
1401
+ this.notifyChange('pins');
1402
+ this.notifyChange('ax');
1403
+ return true;
1404
+ }
1405
+
1406
+ /**
1407
+ * Content written while no board is open (Home) lands on a new board, which
1408
+ * opens: nothing the human was looking at is displaced.
1409
+ */
1410
+ private ensureActiveBoard(): string | null {
1411
+ if (!this._db) return null;
1412
+ if (this._activeBoardId) return this._activeBoardId;
1413
+ const board = createBoardInDB(this._db, defaultBoardName());
1414
+ this._activeBoardId = board.id;
1415
+ setActiveBoardIdInDB(this._db, board.id);
1416
+ return board.id;
1417
+ }
1418
+
1419
+ private hasBoardContent(): boolean {
1420
+ const ax = this.getAxState();
1421
+ return (
1422
+ this._tour !== undefined ||
1423
+ this.nodes.size > 0 ||
1424
+ this.edges.size > 0 ||
1425
+ this.annotations.size > 0 ||
1426
+ ax.workItems.length > 0 ||
1427
+ ax.approvalGates.length > 0 ||
1428
+ ax.reviewAnnotations.length > 0
1429
+ );
1430
+ }
1431
+
870
1432
  /** Debounced save — coalesces rapid mutations into a single write. */
871
1433
  private scheduleSave(): void {
872
1434
  if (!this._db) return;
@@ -878,36 +1440,50 @@ class CanvasStateManager {
878
1440
  }
879
1441
 
880
1442
  flushToDisk(): void {
1443
+ if (!this._db) return;
881
1444
  if (this._saveTimer) {
882
1445
  clearTimeout(this._saveTimer);
883
1446
  this._saveTimer = null;
884
1447
  }
885
- this.saveToDisk();
1448
+ if (!this.saveToDisk()) {
1449
+ throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
1450
+ }
886
1451
  if (this._db) {
887
1452
  try {
888
1453
  checkpointCanvasDb(this._db);
889
1454
  } catch (error) {
890
1455
  logCanvasStateWarning('checkpoint database failed', error, {});
1456
+ throw error;
891
1457
  }
892
1458
  }
893
1459
  }
894
1460
 
895
1461
  /** Write current state to SQLite immediately. */
896
- private saveToDisk(): void {
897
- if (!this._db) return;
1462
+ private saveToDisk(): boolean {
1463
+ if (!this._db) return false;
898
1464
  try {
1465
+ if (!this._activeBoardId && !this.hasBoardContent()) {
1466
+ saveThemeToDB(this._db, this._theme);
1467
+ this._lastPersistenceError = null;
1468
+ return true;
1469
+ }
1470
+ const boardId = this.ensureActiveBoard();
1471
+ if (!boardId) return false;
899
1472
  const payload = this.externalizePersistedStateBlobs({
900
1473
  version: 1,
901
1474
  theme: this._theme,
1475
+ tour: this._tour,
902
1476
  viewport: this._viewport,
903
1477
  nodes: Array.from(this.nodes.values()),
904
1478
  edges: Array.from(this.edges.values()),
905
1479
  annotations: Array.from(this.annotations.values()),
906
1480
  contextPins: Array.from(this._contextPinnedNodeIds),
907
1481
  ax: this.getAxState(),
1482
+ revisionState: this.revisionState(),
908
1483
  });
909
- saveStateToDB(this._db, payload);
1484
+ saveStateToDB(this._db, boardId, payload);
910
1485
  this._lastPersistenceError = null;
1486
+ return true;
911
1487
  } catch (error) {
912
1488
  // Persistence failures are otherwise warn-and-continue (the canvas keeps
913
1489
  // working from memory) — record the failure so /health can report a
@@ -917,6 +1493,7 @@ class CanvasStateManager {
917
1493
  at: new Date().toISOString(),
918
1494
  };
919
1495
  logCanvasStateWarning('save state to sqlite failed', error, {});
1496
+ return false;
920
1497
  }
921
1498
  }
922
1499
 
@@ -929,11 +1506,9 @@ class CanvasStateManager {
929
1506
 
930
1507
  /** Close the SQLite database cleanly. Call on server shutdown. */
931
1508
  close(): void {
932
- if (this._saveTimer) {
933
- clearTimeout(this._saveTimer);
934
- this._saveTimer = null;
935
- this.saveToDisk();
936
- }
1509
+ // A failed debounce has no timer left, but its edits still need saving.
1510
+ // Leave the database and memory intact if the retry fails.
1511
+ if (this._saveTimer || this._lastPersistenceError) this.flushToDisk();
937
1512
  if (this._db) {
938
1513
  try {
939
1514
  finalizeCanvasDbForClose(this._db);
@@ -947,6 +1522,8 @@ class CanvasStateManager {
947
1522
  }
948
1523
  this._db = null;
949
1524
  }
1525
+ this._activeBoardId = null;
1526
+ this._boardGeneration += 1;
950
1527
  }
951
1528
 
952
1529
  // ── Snapshots ───────────────────────────────────────────────
@@ -957,11 +1534,16 @@ class CanvasStateManager {
957
1534
  }
958
1535
 
959
1536
  private applyPersistedState(state: PersistedCanvasState): void {
1537
+ this._boardGeneration += 1;
1538
+ this._tour = state.tour ? structuredClone(state.tour) : undefined;
960
1539
  this.nodes.clear();
961
1540
  this.edges.clear();
962
1541
  this.annotations.clear();
963
1542
  this._contextPinnedNodeIds.clear();
964
1543
  this.ax.resetCanvasBound();
1544
+ this._contentRevision = state.revisionState?.revision ?? 0;
1545
+ this._revisionFloor = state.revisionState?.floor ?? 0;
1546
+ this._deletionTombstones = structuredClone(state.revisionState?.tombstones ?? []);
965
1547
 
966
1548
  this._viewport = {
967
1549
  x: state.viewport?.x ?? 0,
@@ -999,10 +1581,16 @@ class CanvasStateManager {
999
1581
  snapshot: CanvasSnapshot;
1000
1582
  state: PersistedCanvasState;
1001
1583
  } | null {
1584
+ if (!this._activeBoardId) return null;
1585
+
1002
1586
  // Try SQLite first
1003
1587
  if (this._db) {
1004
- const result = loadSnapshotFromDB(this._db, idOrName);
1588
+ const result = loadSnapshotFromDB(this._db, idOrName, this._activeBoardId);
1005
1589
  if (result) return result;
1590
+ const ownedByAnotherBoard = this._db
1591
+ .query<{ found: number }, [string, string]>('SELECT 1 AS found FROM snapshots WHERE id = ? OR name = ? LIMIT 1')
1592
+ .get(idOrName, idOrName);
1593
+ if (ownedByAnotherBoard) return null;
1006
1594
  }
1007
1595
 
1008
1596
  // Fallback to filesystem (legacy on-disk snapshots are still readable in place)
@@ -1090,8 +1678,11 @@ class CanvasStateManager {
1090
1678
  annotations: Array.from(this.annotations.values()),
1091
1679
  contextPins: Array.from(this._contextPinnedNodeIds),
1092
1680
  ax: this.getAxState(),
1681
+ revisionState: this.revisionState(),
1093
1682
  });
1094
- saveSnapshotToDB(this._db, snapshot, payload);
1683
+ const boardId = this.ensureActiveBoard();
1684
+ if (!boardId) return null;
1685
+ saveSnapshotToDB(this._db, boardId, snapshot, { ...payload, tour: this._tour });
1095
1686
  snapshot.nodeCount = payload.nodes.length;
1096
1687
  snapshot.edgeCount = payload.edges.length;
1097
1688
  return snapshot;
@@ -1105,7 +1696,7 @@ class CanvasStateManager {
1105
1696
  listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
1106
1697
  if (this._db) {
1107
1698
  try {
1108
- return listSnapshotsFromDB(this._db, options);
1699
+ return this._activeBoardId ? listSnapshotsFromDB(this._db, this._activeBoardId, options) : [];
1109
1700
  } catch (error) {
1110
1701
  logCanvasStateWarning('list snapshots from db failed', error, {});
1111
1702
  }
@@ -1176,6 +1767,7 @@ class CanvasStateManager {
1176
1767
 
1177
1768
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
1178
1769
  version: 1,
1770
+ tour: this._tour,
1179
1771
  theme: this._theme,
1180
1772
  viewport: structuredClone(this._viewport),
1181
1773
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
@@ -1183,9 +1775,11 @@ class CanvasStateManager {
1183
1775
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1184
1776
  contextPins: Array.from(this._contextPinnedNodeIds),
1185
1777
  ax: this.getAxState(),
1778
+ revisionState: this.revisionState(),
1186
1779
  });
1187
1780
  const nextState: PersistedCanvasState = {
1188
1781
  version: 1,
1782
+ tour: resolved.state.tour,
1189
1783
  theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
1190
1784
  viewport: structuredClone(resolved.state.viewport),
1191
1785
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
@@ -1195,10 +1789,13 @@ class CanvasStateManager {
1195
1789
  : [],
1196
1790
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1197
1791
  ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1792
+ revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
1198
1793
  };
1199
1794
 
1200
1795
  try {
1201
- this.applyPersistedState(nextState);
1796
+ const revisionBeforeRestore = this._contentRevision;
1797
+ this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
1798
+ this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
1202
1799
  this.scheduleSave();
1203
1800
  this.notifyChange('nodes');
1204
1801
  this.notifyChange('pins');
@@ -1207,14 +1804,20 @@ class CanvasStateManager {
1207
1804
  operationType: 'restoreSnapshot',
1208
1805
  description: `Restored snapshot "${resolved.snapshot.name}"`,
1209
1806
  forward: this.suppressed(() => {
1210
- this.applyPersistedState(nextState);
1807
+ const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
1808
+ const revisionBeforeReplay = this._contentRevision;
1809
+ this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
1810
+ this.restampRestoredContent(before, revisionBeforeReplay);
1211
1811
  this.scheduleSave();
1212
1812
  this.notifyChange('nodes');
1213
1813
  this.notifyChange('pins');
1214
1814
  this.notifyChange('ax');
1215
1815
  }),
1216
1816
  inverse: this.suppressed(() => {
1217
- this.applyPersistedState(previousState);
1817
+ const before = Array.from(this.nodes.values(), (node) => structuredClone(node));
1818
+ const revisionBeforeReplay = this._contentRevision;
1819
+ this.applyPersistedState({ ...previousState, revisionState: this.revisionState() });
1820
+ this.restampRestoredContent(before, revisionBeforeReplay);
1218
1821
  this.scheduleSave();
1219
1822
  this.notifyChange('nodes');
1220
1823
  this.notifyChange('pins');
@@ -1233,9 +1836,13 @@ class CanvasStateManager {
1233
1836
  }
1234
1837
 
1235
1838
  /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
1236
- getSnapshotData(
1237
- idOrName: string,
1238
- ): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[]; annotations: CanvasAnnotation[] } | null {
1839
+ getSnapshotData(idOrName: string): {
1840
+ name: string;
1841
+ nodes: CanvasNodeState[];
1842
+ edges: CanvasEdge[];
1843
+ annotations: CanvasAnnotation[];
1844
+ tour?: Tour;
1845
+ } | null {
1239
1846
  const resolved = this.readResolvedSnapshot(idOrName);
1240
1847
  if (!resolved) return null;
1241
1848
  const state = {
@@ -1246,6 +1853,7 @@ class CanvasStateManager {
1246
1853
  };
1247
1854
  return {
1248
1855
  name: resolved.snapshot.name,
1856
+ ...(state.tour ? { tour: structuredClone(state.tour) } : {}),
1249
1857
  nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
1250
1858
  edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
1251
1859
  annotations: Array.isArray(state.annotations)
@@ -1256,9 +1864,9 @@ class CanvasStateManager {
1256
1864
 
1257
1865
  /** Delete a snapshot. */
1258
1866
  renameSnapshot(id: string, name: string): boolean {
1259
- if (!this._db) return false;
1867
+ if (!this._db || !this._activeBoardId) return false;
1260
1868
  try {
1261
- return renameSnapshotInDB(this._db, id, name);
1869
+ return renameSnapshotInDB(this._db, this._activeBoardId, id, name);
1262
1870
  } catch (error) {
1263
1871
  logCanvasStateWarning('rename snapshot failed', error, { id, name });
1264
1872
  return false;
@@ -1266,12 +1874,20 @@ class CanvasStateManager {
1266
1874
  }
1267
1875
 
1268
1876
  deleteSnapshot(id: string): boolean {
1877
+ if (!this._activeBoardId) return false;
1269
1878
  // Try SQLite first
1270
1879
  if (this._db) {
1271
1880
  try {
1272
- if (deleteSnapshotFromDB(this._db, id)) return true;
1881
+ const snapshot = this._db
1882
+ .query<{ board_id: string }, [string]>('SELECT board_id FROM snapshots WHERE id = ?')
1883
+ .get(id);
1884
+ if (snapshot) {
1885
+ if (snapshot.board_id !== this._activeBoardId) return false;
1886
+ return deleteSnapshotFromDB(this._db, id);
1887
+ }
1273
1888
  } catch (error) {
1274
1889
  logCanvasStateWarning('delete snapshot from db failed', error, { id });
1890
+ return false;
1275
1891
  }
1276
1892
  }
1277
1893
 
@@ -1308,12 +1924,77 @@ class CanvasStateManager {
1308
1924
 
1309
1925
  // ── Node CRUD ──────────────────────────────────────────────
1310
1926
 
1927
+ private restampRestoredContent(beforeNodes: CanvasNodeState[], minimumRevision: number): void {
1928
+ this._contentRevision = Math.max(this._contentRevision, minimumRevision);
1929
+ const before = new Map(beforeNodes.map((node) => [node.id, node]));
1930
+ const restoredIds = new Set(this.nodes.keys());
1931
+ for (const [id, node] of this.nodes) {
1932
+ const old = before.get(id);
1933
+ if (old && !this.nodeContentChanged(old, node)) {
1934
+ this.nodes.set(id, { ...node, contentRevision: old.contentRevision, lastEditedBy: old.lastEditedBy });
1935
+ continue;
1936
+ }
1937
+ const revision = this.nextContentRevision();
1938
+ this.nodes.set(id, { ...node, lastEditedBy: currentActor(), contentRevision: revision });
1939
+ }
1940
+ for (const old of beforeNodes) {
1941
+ if (!restoredIds.has(old.id)) this.recordDeletion(old.id);
1942
+ }
1943
+ }
1944
+
1945
+ private semanticData(data: Record<string, unknown>): Record<string, unknown> {
1946
+ const copy = { ...data };
1947
+ delete copy.strictSize;
1948
+ delete copy.arrangeLocked;
1949
+ return copy;
1950
+ }
1951
+
1952
+ private nodeContentChanged(a: CanvasNodeState, b: CanvasNodeState): boolean {
1953
+ return a.type !== b.type || JSON.stringify(this.semanticData(a.data)) !== JSON.stringify(this.semanticData(b.data));
1954
+ }
1955
+
1956
+ private setSemanticNode(node: CanvasNodeState): void {
1957
+ const existing = this.nodes.get(node.id);
1958
+ if (!existing || !this.nodeContentChanged(existing, node)) {
1959
+ this.nodes.set(node.id, node);
1960
+ return;
1961
+ }
1962
+ const data = { ...node.data };
1963
+ if (data.agentSummary === existing.data.agentSummary) delete data.agentSummary;
1964
+ if (data.summary === existing.data.summary) delete data.summary;
1965
+ this.nodes.set(node.id, {
1966
+ ...node,
1967
+ data,
1968
+ createdBy: existing.createdBy,
1969
+ lastEditedBy: currentActor(),
1970
+ contentRevision: this.nextContentRevision(),
1971
+ });
1972
+ }
1973
+
1974
+ private recordDeletion(nodeId: string): void {
1975
+ const tombstone = { nodeId, revision: this.nextContentRevision(), deletedBy: currentActor() };
1976
+ this._deletionTombstones.push(tombstone);
1977
+ const limit = 500;
1978
+ if (this._deletionTombstones.length > limit) {
1979
+ const removed = this._deletionTombstones.splice(0, this._deletionTombstones.length - limit);
1980
+ this._revisionFloor = Math.max(this._revisionFloor, removed.at(-1)?.revision ?? 0);
1981
+ }
1982
+ }
1983
+
1311
1984
  get viewport(): ViewportState {
1312
1985
  return structuredClone(this._viewport);
1313
1986
  }
1314
1987
 
1315
1988
  addNode(node: CanvasNodeState): void {
1316
- const cloned = structuredClone(this.normalizeNode(node));
1989
+ const actor = currentActor();
1990
+ const cloned = structuredClone(
1991
+ this.normalizeNode({
1992
+ ...node,
1993
+ createdBy: actor,
1994
+ lastEditedBy: actor,
1995
+ contentRevision: this.nextContentRevision(),
1996
+ }),
1997
+ );
1317
1998
  this.nodes.set(node.id, cloned);
1318
1999
  this.scheduleSave();
1319
2000
  this.notifyChange('nodes');
@@ -1336,11 +2017,43 @@ class CanvasStateManager {
1336
2017
  updateNode(id: string, patch: Partial<CanvasNodeState>): void {
1337
2018
  const existing = this.nodes.get(id);
1338
2019
  if (!existing) return;
2020
+ if (patch.data) {
2021
+ const attachmentId = existing.data.attachmentId;
2022
+ if (typeof attachmentId === 'string' && patch.data.attachmentId !== attachmentId) {
2023
+ throw new Error('Attachment identity is immutable.');
2024
+ }
2025
+ const source = existing.data.source;
2026
+ const importedSource =
2027
+ source && typeof source === 'object' && typeof (source as Record<string, unknown>).attachmentId === 'string';
2028
+ if (importedSource && JSON.stringify(patch.data.source) !== JSON.stringify(source)) {
2029
+ throw new Error('Import provenance is immutable.');
2030
+ }
2031
+ }
1339
2032
  const oldSnapshot = structuredClone(existing);
1340
2033
  if (existing.type === 'group' && patch.position) {
1341
2034
  this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
1342
2035
  }
1343
- const nextNode = this.normalizeNode({ ...existing, ...patch });
2036
+ let nextNode = this.normalizeNode({ ...existing, ...patch });
2037
+ if (this.nodeContentChanged(existing, nextNode)) {
2038
+ const data = { ...nextNode.data };
2039
+ const suppliedData = patch.data;
2040
+ if (!suppliedData || suppliedData.agentSummary === existing.data.agentSummary) delete data.agentSummary;
2041
+ if (!suppliedData || suppliedData.summary === existing.data.summary) delete data.summary;
2042
+ nextNode = {
2043
+ ...nextNode,
2044
+ data,
2045
+ createdBy: existing.createdBy ?? unknownActor(),
2046
+ lastEditedBy: currentActor(),
2047
+ contentRevision: this.nextContentRevision(),
2048
+ };
2049
+ } else {
2050
+ nextNode = {
2051
+ ...nextNode,
2052
+ createdBy: existing.createdBy,
2053
+ lastEditedBy: existing.lastEditedBy,
2054
+ contentRevision: existing.contentRevision,
2055
+ };
2056
+ }
1344
2057
  this.nodes.set(id, nextNode);
1345
2058
  const parentGroupId = existing.data.parentGroup as string | undefined;
1346
2059
  if (parentGroupId) {
@@ -1358,7 +2071,16 @@ class CanvasStateManager {
1358
2071
  description: `Updated node "${(existing.data.title as string) ?? id}"`,
1359
2072
  forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
1360
2073
  inverse: this.suppressed(() => {
1361
- this.nodes.set(id, structuredClone(oldSnapshot));
2074
+ const restored = structuredClone(oldSnapshot);
2075
+ const current = this.nodes.get(id);
2076
+ if (current && this.nodeContentChanged(current, restored)) {
2077
+ restored.contentRevision = this.nextContentRevision();
2078
+ restored.lastEditedBy = currentActor();
2079
+ } else if (current) {
2080
+ restored.contentRevision = current.contentRevision;
2081
+ restored.lastEditedBy = current.lastEditedBy;
2082
+ }
2083
+ this.nodes.set(id, restored);
1362
2084
  this.scheduleSave();
1363
2085
  this.notifyChange('nodes');
1364
2086
  }),
@@ -1379,6 +2101,7 @@ class CanvasStateManager {
1379
2101
  let released: string[] = [];
1380
2102
 
1381
2103
  if (existing) {
2104
+ this.recordDeletion(id);
1382
2105
  const parentGroupId = existing.data.parentGroup as string | undefined;
1383
2106
  const parent = parentGroupId ? this.nodes.get(parentGroupId) : undefined;
1384
2107
  const enclosing = parent && parent.type === 'group' ? parent : null;
@@ -1391,13 +2114,13 @@ class CanvasStateManager {
1391
2114
  const d = { ...child.data };
1392
2115
  if (enclosing) d.parentGroup = enclosing.id;
1393
2116
  else delete d.parentGroup;
1394
- this.nodes.set(cid, { ...child, data: d });
2117
+ this.setSemanticNode({ ...child, data: d });
1395
2118
  }
1396
2119
  }
1397
2120
  if (enclosing && enclosingBefore) {
1398
2121
  // The removed node leaves the enclosing group; a dissolved group's children take its place.
1399
2122
  const children = enclosingBefore.children.flatMap((cid) => (cid === id ? released : [cid]));
1400
- this.nodes.set(enclosing.id, { ...enclosing, data: { ...enclosing.data, children } });
2123
+ this.setSemanticNode({ ...enclosing, data: { ...enclosing.data, children } });
1401
2124
  }
1402
2125
  }
1403
2126
 
@@ -1451,15 +2174,17 @@ class CanvasStateManager {
1451
2174
  forward: this.suppressed(() => this.removeNode(id)),
1452
2175
  inverse: this.suppressed(() => {
1453
2176
  this.addNode(structuredClone(cloned));
2177
+ const restored = this.nodes.get(id);
2178
+ if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
1454
2179
  // A restored group takes its children back; the enclosing group's membership is restored verbatim.
1455
2180
  for (const cid of released) {
1456
2181
  const child = this.nodes.get(cid);
1457
- if (child) this.nodes.set(cid, { ...child, data: { ...child.data, parentGroup: id } });
2182
+ if (child) this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: id } });
1458
2183
  }
1459
2184
  if (enclosingBefore) {
1460
2185
  const enclosing = this.nodes.get(enclosingBefore.id);
1461
2186
  if (enclosing) {
1462
- this.nodes.set(enclosing.id, {
2187
+ this.setSemanticNode({
1463
2188
  ...enclosing,
1464
2189
  data: { ...enclosing.data, children: enclosingBefore.children },
1465
2190
  });
@@ -1620,6 +2345,7 @@ class CanvasStateManager {
1620
2345
 
1621
2346
  getLayout(): CanvasLayout {
1622
2347
  return {
2348
+ tour: this.getTour(),
1623
2349
  viewport: structuredClone(this._viewport),
1624
2350
  theme: this._theme,
1625
2351
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.nodeForRead(node))),
@@ -1630,6 +2356,7 @@ class CanvasStateManager {
1630
2356
 
1631
2357
  getLayoutForPersistence(): CanvasLayout {
1632
2358
  return {
2359
+ tour: this.getTour(),
1633
2360
  viewport: structuredClone(this._viewport),
1634
2361
  theme: this._theme,
1635
2362
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
@@ -1638,6 +2365,24 @@ class CanvasStateManager {
1638
2365
  };
1639
2366
  }
1640
2367
 
2368
+ getTour(): Tour | undefined {
2369
+ return this._tour ? structuredClone(this._tour) : undefined;
2370
+ }
2371
+
2372
+ setTour(tour: Tour | null): void {
2373
+ const previous = this.getTour();
2374
+ this._tour = tour === null ? undefined : tourSchema.parse(tour);
2375
+ const next = this.getTour();
2376
+ this.scheduleSave();
2377
+ this.notifyChange('nodes');
2378
+ this.recordMutation({
2379
+ operationType: 'viewport',
2380
+ description: 'Updated board tour',
2381
+ forward: this.suppressed(() => this.setTour(next ?? null)),
2382
+ inverse: this.suppressed(() => this.setTour(previous ?? null)),
2383
+ });
2384
+ }
2385
+
1641
2386
  applyUpdates(updates: CanvasNodeUpdate[], options: ApplyUpdatesOptions = {}): { applied: number; skipped: number } {
1642
2387
  let applied = 0;
1643
2388
  let skipped = 0;
@@ -2100,6 +2845,21 @@ class CanvasStateManager {
2100
2845
  return this.ax.getAxTimelineSummary();
2101
2846
  }
2102
2847
 
2848
+ /** Records one agent context read (diagnostics; never notifies, so a read cannot trigger reads). */
2849
+ recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
2850
+ if (!this._db) return null;
2851
+ try {
2852
+ return appendContextReadToDB(this._db, input, boardId);
2853
+ } catch (error) {
2854
+ logCanvasStateWarning('record context read failed', error);
2855
+ return null;
2856
+ }
2857
+ }
2858
+
2859
+ getContextReads(limit?: number): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
2860
+ return this._db ? loadContextReadsFromDB(this._db, limit) : { reads: [], summary: [] };
2861
+ }
2862
+
2103
2863
  getAxTimeline(q: AxTimelineQuery = {}): {
2104
2864
  events: PmxAxEvent[];
2105
2865
  evidence: PmxAxEvidence[];
@@ -2156,10 +2916,10 @@ class CanvasStateManager {
2156
2916
  }
2157
2917
 
2158
2918
  // Apply
2159
- this.nodes.set(groupId, { ...group, data: { ...group.data, children: merged } });
2919
+ this.setSemanticNode({ ...group, data: { ...group.data, children: merged } });
2160
2920
  for (const id of validIds) {
2161
2921
  const child = this.nodes.get(id)!;
2162
- this.nodes.set(id, { ...child, data: { ...child.data, parentGroup: groupId } });
2922
+ this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: groupId } });
2163
2923
  }
2164
2924
  if (options.preservePositions === true) {
2165
2925
  if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
@@ -2182,14 +2942,14 @@ class CanvasStateManager {
2182
2942
  forward: this.suppressed(() => this.groupNodes(groupId, validIds, options)),
2183
2943
  inverse: this.suppressed(() => {
2184
2944
  const g = this.nodes.get(groupId);
2185
- if (g) this.nodes.set(groupId, { ...g, data: { ...g.data, children: oldChildren } });
2945
+ if (g) this.setSemanticNode({ ...g, data: { ...g.data, children: oldChildren } });
2186
2946
  for (const [id, oldParent] of oldParents) {
2187
2947
  const c = this.nodes.get(id);
2188
2948
  if (!c) continue;
2189
2949
  const d = { ...c.data };
2190
2950
  if (oldParent) d.parentGroup = oldParent;
2191
2951
  else delete d.parentGroup;
2192
- this.nodes.set(id, { ...c, data: d });
2952
+ this.setSemanticNode({ ...c, data: d });
2193
2953
  }
2194
2954
  this.scheduleSave();
2195
2955
  this.notifyChange('nodes');
@@ -2225,13 +2985,13 @@ class CanvasStateManager {
2225
2985
 
2226
2986
  const snapshot = childIds.slice();
2227
2987
 
2228
- this.nodes.set(groupId, { ...group, data: { ...group.data, children: [] } });
2988
+ this.setSemanticNode({ ...group, data: { ...group.data, children: [] } });
2229
2989
  for (const id of childIds) {
2230
2990
  const child = this.nodes.get(id);
2231
2991
  if (!child) continue;
2232
2992
  const d = { ...child.data };
2233
2993
  delete d.parentGroup;
2234
- this.nodes.set(id, { ...child, data: d });
2994
+ this.setSemanticNode({ ...child, data: d });
2235
2995
  }
2236
2996
 
2237
2997
  this.scheduleSave();
@@ -2246,12 +3006,15 @@ class CanvasStateManager {
2246
3006
  }
2247
3007
 
2248
3008
  clear(): void {
3009
+ const oldTour = this.getTour();
3010
+ this._tour = undefined;
2249
3011
  const oldNodes = Array.from(this.nodes.values()).map((n) => structuredClone(n));
2250
3012
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
2251
3013
  const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
2252
3014
  const oldPins = Array.from(this._contextPinnedNodeIds);
2253
3015
  const oldAxState = this.getAxState();
2254
3016
  const oldViewport = { ...this._viewport };
3017
+ for (const node of oldNodes) this.recordDeletion(node.id);
2255
3018
  this.nodes.clear();
2256
3019
  this.edges.clear();
2257
3020
  this.annotations.clear();
@@ -2274,6 +3037,7 @@ class CanvasStateManager {
2274
3037
  for (const e of oldEdges) this.addEdge(structuredClone(e));
2275
3038
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
2276
3039
  this.setContextPins(oldPins);
3040
+ this.setTour(oldTour ?? null);
2277
3041
  this.ax.applyPersistedAx(oldAxState);
2278
3042
  this.setViewport(oldViewport);
2279
3043
  this.notifyChange('ax');