pmx-canvas 0.6.5 → 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 (169) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +67 -3
  2. package/CHANGELOG.md +54 -0
  3. package/Readme.md +29 -5
  4. package/dist/canvas/global.css +440 -24
  5. package/dist/canvas/index.js +90 -75
  6. package/dist/types/cli/agent.d.ts +3 -0
  7. package/dist/types/cli/commands/backup.d.ts +1 -0
  8. package/dist/types/cli/commands/boards.d.ts +1 -0
  9. package/dist/types/cli/commands/export.d.ts +1 -0
  10. package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
  11. package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
  12. package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
  13. package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
  14. package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
  15. package/dist/types/client/canvas/HomeView.d.ts +9 -0
  16. package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
  17. package/dist/types/client/nodes/BoardNode.d.ts +5 -0
  18. package/dist/types/client/state/boards-store.d.ts +79 -0
  19. package/dist/types/client/state/canvas-store.d.ts +2 -0
  20. package/dist/types/client/state/intent-bridge.d.ts +1 -0
  21. package/dist/types/client/state/session-store.d.ts +7 -0
  22. package/dist/types/client/types.d.ts +2 -2
  23. package/dist/types/json-render/catalog.d.ts +1 -1
  24. package/dist/types/json-render/charts/definitions.d.ts +1 -1
  25. package/dist/types/json-render/server.d.ts +6 -0
  26. package/dist/types/mcp/canvas-access.d.ts +10 -3
  27. package/dist/types/mcp/context-uri-template.d.ts +6 -0
  28. package/dist/types/server/agent-presence.d.ts +1 -1
  29. package/dist/types/server/attribution.d.ts +11 -0
  30. package/dist/types/server/ax-context.d.ts +3 -1
  31. package/dist/types/server/ax-interaction.d.ts +2 -2
  32. package/dist/types/server/ax-state.d.ts +13 -3
  33. package/dist/types/server/backup.d.ts +36 -0
  34. package/dist/types/server/board-export.d.ts +45 -0
  35. package/dist/types/server/canvas-db.d.ts +63 -9
  36. package/dist/types/server/canvas-operations.d.ts +23 -0
  37. package/dist/types/server/canvas-provenance.d.ts +1 -1
  38. package/dist/types/server/canvas-state.d.ts +140 -3
  39. package/dist/types/server/context-brief.d.ts +111 -0
  40. package/dist/types/server/context-reads.d.ts +55 -0
  41. package/dist/types/server/document-import.d.ts +66 -0
  42. package/dist/types/server/index.d.ts +103 -4
  43. package/dist/types/server/mutation-history.d.ts +1 -0
  44. package/dist/types/server/operations/invoker.d.ts +2 -0
  45. package/dist/types/server/operations/mcp.d.ts +7 -1
  46. package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
  47. package/dist/types/server/operations/ops/backup.d.ts +2 -0
  48. package/dist/types/server/operations/ops/boards.d.ts +10 -0
  49. package/dist/types/server/operations/ops/export.d.ts +2 -0
  50. package/dist/types/server/operations/ops/imports.d.ts +2 -0
  51. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  52. package/dist/types/server/operations/ops/query.d.ts +2 -0
  53. package/dist/types/server/operations/registry.d.ts +9 -1
  54. package/dist/types/server/operations/types.d.ts +1 -1
  55. package/dist/types/server/server.d.ts +1 -0
  56. package/dist/types/server/trace-manager.d.ts +7 -0
  57. package/dist/types/server/webpage-node.d.ts +22 -1
  58. package/dist/types/server/workbench-auth.d.ts +3 -0
  59. package/dist/types/shared/boards.d.ts +6 -0
  60. package/docs/RELEASE.md +5 -0
  61. package/docs/cli.md +68 -0
  62. package/docs/http-api.md +198 -2
  63. package/docs/mcp.md +71 -6
  64. package/docs/node-types.md +26 -0
  65. package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
  66. package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
  67. package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
  68. package/docs/plans/plan-013-static-board-export.md +47 -0
  69. package/docs/plans/plan-014-connected-board-memory.md +140 -0
  70. package/docs/product-vision-2026-09.md +118 -18
  71. package/docs/sdk.md +50 -0
  72. package/package.json +1 -1
  73. package/skills/pmx-canvas/SKILL.md +53 -4
  74. package/skills/pmx-canvas/references/full-reference.md +43 -7
  75. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
  76. package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
  77. package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
  78. package/skills/pmx-canvas-testing/SKILL.md +4 -0
  79. package/src/cli/agent.ts +4 -0
  80. package/src/cli/commands/ax.ts +13 -0
  81. package/src/cli/commands/backup.ts +66 -0
  82. package/src/cli/commands/boards.ts +71 -0
  83. package/src/cli/commands/export.ts +30 -0
  84. package/src/cli/commands/nodes.ts +4 -1
  85. package/src/cli/commands/pins.ts +6 -2
  86. package/src/cli/commands/pump.ts +3 -0
  87. package/src/cli/commands/query.ts +33 -7
  88. package/src/cli/commands/view.ts +7 -5
  89. package/src/cli/index.ts +19 -9
  90. package/src/client/App.tsx +21 -3
  91. package/src/client/canvas/BoardCopyDialog.tsx +153 -0
  92. package/src/client/canvas/BoardFolderDialog.tsx +120 -0
  93. package/src/client/canvas/BoardLinkDialog.tsx +93 -0
  94. package/src/client/canvas/BoardSwitcher.tsx +149 -0
  95. package/src/client/canvas/CanvasViewport.tsx +3 -0
  96. package/src/client/canvas/CommandPalette.tsx +107 -4
  97. package/src/client/canvas/ContextMenu.tsx +4 -0
  98. package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
  99. package/src/client/canvas/ExportDialog.tsx +197 -0
  100. package/src/client/canvas/HomeView.tsx +277 -0
  101. package/src/client/canvas/SessionPanel.tsx +28 -1
  102. package/src/client/canvas/ToolRail.tsx +38 -3
  103. package/src/client/canvas/TopBar.tsx +34 -18
  104. package/src/client/canvas/import-files.ts +19 -1
  105. package/src/client/canvas/kind-colors.ts +1 -0
  106. package/src/client/icons.tsx +2 -0
  107. package/src/client/nodes/AttachmentNode.tsx +148 -0
  108. package/src/client/nodes/BoardNode.tsx +35 -0
  109. package/src/client/nodes/FileNode.tsx +6 -0
  110. package/src/client/nodes/MarkdownNode.tsx +47 -19
  111. package/src/client/nodes/ax-node-actions.ts +2 -5
  112. package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
  113. package/src/client/state/boards-store.ts +167 -0
  114. package/src/client/state/canvas-store.ts +22 -0
  115. package/src/client/state/intent-bridge.ts +30 -2
  116. package/src/client/state/node-factory.ts +1 -0
  117. package/src/client/state/session-store.ts +5 -0
  118. package/src/client/state/sse-bridge.ts +49 -5
  119. package/src/client/theme/global.css +440 -24
  120. package/src/client/types.ts +2 -0
  121. package/src/json-render/server.ts +16 -9
  122. package/src/mcp/canvas-access.ts +59 -22
  123. package/src/mcp/context-uri-template.ts +28 -0
  124. package/src/mcp/server.ts +122 -15
  125. package/src/server/agent-context.ts +116 -29
  126. package/src/server/attribution.ts +29 -0
  127. package/src/server/ax-context.ts +4 -2
  128. package/src/server/ax-interaction.ts +1 -0
  129. package/src/server/ax-state-manager.ts +72 -3
  130. package/src/server/ax-state.ts +41 -0
  131. package/src/server/backup.ts +178 -0
  132. package/src/server/board-export.ts +686 -0
  133. package/src/server/canvas-db.ts +599 -107
  134. package/src/server/canvas-operations.ts +68 -9
  135. package/src/server/canvas-provenance.ts +1 -0
  136. package/src/server/canvas-schema.ts +12 -0
  137. package/src/server/canvas-state.ts +774 -45
  138. package/src/server/context-brief.ts +372 -0
  139. package/src/server/context-reads.ts +238 -0
  140. package/src/server/demo-state.json +659 -2
  141. package/src/server/document-import.ts +223 -0
  142. package/src/server/index.ts +231 -5
  143. package/src/server/mutation-history.ts +10 -13
  144. package/src/server/operations/composites.ts +26 -4
  145. package/src/server/operations/http.ts +9 -3
  146. package/src/server/operations/index.ts +8 -0
  147. package/src/server/operations/invoker.ts +14 -3
  148. package/src/server/operations/mcp.ts +50 -11
  149. package/src/server/operations/ops/app.ts +5 -0
  150. package/src/server/operations/ops/ax-read.ts +10 -6
  151. package/src/server/operations/ops/ax-timeline.ts +75 -0
  152. package/src/server/operations/ops/backup.ts +134 -0
  153. package/src/server/operations/ops/batch.ts +8 -1
  154. package/src/server/operations/ops/boards.ts +351 -0
  155. package/src/server/operations/ops/export.ts +74 -0
  156. package/src/server/operations/ops/ext-app.ts +4 -0
  157. package/src/server/operations/ops/imports.ts +220 -0
  158. package/src/server/operations/ops/nodes.ts +57 -5
  159. package/src/server/operations/ops/query.ts +206 -9
  160. package/src/server/operations/registry.ts +123 -31
  161. package/src/server/operations/types.ts +1 -1
  162. package/src/server/server.ts +146 -2
  163. package/src/server/spatial-analysis.ts +9 -17
  164. package/src/server/trace-manager.ts +19 -0
  165. package/src/server/web-artifacts.ts +2 -1
  166. package/src/server/webpage-node.ts +173 -13
  167. package/src/server/workbench-auth.ts +11 -0
  168. package/src/shared/boards.ts +18 -0
  169. package/src/shared/node-sizes.ts +1 -0
@@ -12,10 +12,19 @@
12
12
  * the one-shot boot migration into SQLite was retired.
13
13
  */
14
14
 
15
- import { createHash } from 'node:crypto';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
16
  import { tourSchema, type Tour } from '../shared/tour.js';
17
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
18
- import { isAbsolute, join, dirname, relative } from 'node:path';
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';
19
28
  import { gzipSync, gunzipSync } from 'node:zlib';
20
29
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
21
30
  import {
@@ -29,13 +38,41 @@ import {
29
38
  renameSnapshotInDB,
30
39
  writeBlobToDB,
31
40
  readBlobFromDB,
32
- isDbPopulated,
33
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,
34
57
  finalizeCanvasDbForClose,
35
58
  type PersistedCanvasState,
36
59
  type CanvasTheme,
37
60
  type AxTimelineQuery,
38
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';
39
76
  import { normalizeCanvasTheme } from './canvas-db.js';
40
77
  import {
41
78
  type CanvasPlacementRect,
@@ -74,6 +111,14 @@ import {
74
111
  type PmxAxTimelineSummary,
75
112
  } from './ax-state.js';
76
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';
77
122
 
78
123
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
79
124
  console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
@@ -170,6 +215,7 @@ export interface CanvasNodeState {
170
215
  | 'webpage'
171
216
  | 'json-render'
172
217
  | 'graph'
218
+ | 'board'
173
219
  | 'prompt'
174
220
  | 'response'
175
221
  | 'status'
@@ -188,6 +234,30 @@ export interface CanvasNodeState {
188
234
  collapsed: boolean;
189
235
  pinned: boolean;
190
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[];
191
261
  }
192
262
 
193
263
  export interface ViewportState {
@@ -241,6 +311,11 @@ export interface CanvasNodeUpdate {
241
311
 
242
312
  export type CanvasChangeType = 'pins' | 'nodes' | 'ax' | 'ax-timeline';
243
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
+
244
319
  export interface MutationRecordInfo {
245
320
  operationType:
246
321
  | 'addNode'
@@ -339,6 +414,9 @@ class CanvasStateManager {
339
414
  private _theme: CanvasTheme = 'dark';
340
415
  private _contextPinnedNodeIds = new Set<string>();
341
416
  private _workspaceRoot = process.cwd();
417
+ private _contentRevision = 0;
418
+ private _revisionFloor = 0;
419
+ private _deletionTombstones: NodeDeletionTombstone[] = [];
342
420
 
343
421
  // ── AX state (canvas-bound + timeline + host partitions) ──────────
344
422
  // Extracted into a dedicated manager (plan-007 Slice A). CanvasStateManager
@@ -506,12 +584,58 @@ class CanvasStateManager {
506
584
  }
507
585
 
508
586
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
587
+ const data = { ...node.data };
588
+ delete data.createdBy;
589
+ delete data.lastEditedBy;
590
+ delete data.contentRevision;
509
591
  return {
510
592
  ...node,
511
- 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,
512
597
  };
513
598
  }
514
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)),
632
+ };
633
+ }
634
+
635
+ getContentRevision(): BoardContentRevision {
636
+ return { revision: this._contentRevision, retentionFloor: this._revisionFloor };
637
+ }
638
+
515
639
  private nodeForRead(node: CanvasNodeState): CanvasNodeState {
516
640
  const resolved = this.resolveNodeDataBlobs(node);
517
641
  return {
@@ -640,6 +764,7 @@ class CanvasStateManager {
640
764
 
641
765
  // ── Persistence ────────────────────────────────────────────
642
766
  private _db: import('bun:sqlite').Database | null = null;
767
+ private _dbPath: string | null = null;
643
768
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
644
769
 
645
770
  /** Workspace root backing persistence and workspace-relative path resolution. */
@@ -677,7 +802,8 @@ class CanvasStateManager {
677
802
  // 0.4.0 — the one-shot boot migration that copied them into SQLite and
678
803
  // renamed the originals to `.bak` was retired.
679
804
  try {
680
- this._db = openCanvasDb(dbPath);
805
+ this._db = openCanvasDb(dbPath, { migratedBoardName: basename(workspaceRoot) || 'Board' });
806
+ this._dbPath = dbPath;
681
807
  } catch (error) {
682
808
  logCanvasStateWarning('open canvas database failed', error, { dbPath });
683
809
  }
@@ -789,7 +915,7 @@ class CanvasStateManager {
789
915
  }
790
916
 
791
917
  private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
792
- if (node.type !== 'mcp-app') return node;
918
+ if (node.type !== 'mcp-app' && node.type !== 'html') return node;
793
919
  let changed = false;
794
920
  const data = { ...node.data };
795
921
  for (const [key, value] of Object.entries(data)) {
@@ -803,7 +929,7 @@ class CanvasStateManager {
803
929
  }
804
930
 
805
931
  private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
806
- if (node.type !== 'mcp-app') return node;
932
+ if (node.type !== 'mcp-app' && node.type !== 'html') return node;
807
933
  let changed = false;
808
934
  const data = { ...node.data };
809
935
  for (const [key, value] of Object.entries(data)) {
@@ -843,22 +969,27 @@ class CanvasStateManager {
843
969
  annotations: [],
844
970
  contextPins: [],
845
971
  ax: createEmptyAxState(),
972
+ revisionState: { revision: 0, floor: 0, tombstones: [] },
846
973
  };
847
974
  }
848
975
 
849
- /** 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
+ */
850
980
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
851
981
  // Host capability lives in its own table (not snapshotted / not in PmxAxState).
852
982
  this.ax.loadHostCapabilityFromDb();
853
- // Only a populated DB counts as saved state (legacy `state.json` loading was
854
- // retired with the 0.4.0 boot-migration removal).
855
- if (this._db && isDbPopulated(this._db)) {
983
+ if (this._db) {
856
984
  try {
857
- const state = loadStateFromDB(this._db);
858
- if (state) {
985
+ const boardId = getActiveBoardIdFromDB(this._db);
986
+ const state = boardId ? loadStateFromDB(this._db, boardId) : null;
987
+ if (boardId && state) {
859
988
  this.applyPersistedState(state);
989
+ this._activeBoardId = boardId;
860
990
  return true;
861
991
  }
992
+ this._theme = normalizeCanvasTheme(readThemeFromDB(this._db), this._theme);
862
993
  } catch (error) {
863
994
  logCanvasStateWarning('load state from sqlite failed', error, {});
864
995
  }
@@ -870,6 +1001,434 @@ class CanvasStateManager {
870
1001
  return false;
871
1002
  }
872
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
+
873
1432
  /** Debounced save — coalesces rapid mutations into a single write. */
874
1433
  private scheduleSave(): void {
875
1434
  if (!this._db) return;
@@ -881,24 +1440,35 @@ class CanvasStateManager {
881
1440
  }
882
1441
 
883
1442
  flushToDisk(): void {
1443
+ if (!this._db) return;
884
1444
  if (this._saveTimer) {
885
1445
  clearTimeout(this._saveTimer);
886
1446
  this._saveTimer = null;
887
1447
  }
888
- this.saveToDisk();
1448
+ if (!this.saveToDisk()) {
1449
+ throw new Error(this._lastPersistenceError?.message ?? 'Failed to save canvas state.');
1450
+ }
889
1451
  if (this._db) {
890
1452
  try {
891
1453
  checkpointCanvasDb(this._db);
892
1454
  } catch (error) {
893
1455
  logCanvasStateWarning('checkpoint database failed', error, {});
1456
+ throw error;
894
1457
  }
895
1458
  }
896
1459
  }
897
1460
 
898
1461
  /** Write current state to SQLite immediately. */
899
- private saveToDisk(): void {
900
- if (!this._db) return;
1462
+ private saveToDisk(): boolean {
1463
+ if (!this._db) return false;
901
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;
902
1472
  const payload = this.externalizePersistedStateBlobs({
903
1473
  version: 1,
904
1474
  theme: this._theme,
@@ -909,9 +1479,11 @@ class CanvasStateManager {
909
1479
  annotations: Array.from(this.annotations.values()),
910
1480
  contextPins: Array.from(this._contextPinnedNodeIds),
911
1481
  ax: this.getAxState(),
1482
+ revisionState: this.revisionState(),
912
1483
  });
913
- saveStateToDB(this._db, payload);
1484
+ saveStateToDB(this._db, boardId, payload);
914
1485
  this._lastPersistenceError = null;
1486
+ return true;
915
1487
  } catch (error) {
916
1488
  // Persistence failures are otherwise warn-and-continue (the canvas keeps
917
1489
  // working from memory) — record the failure so /health can report a
@@ -921,6 +1493,7 @@ class CanvasStateManager {
921
1493
  at: new Date().toISOString(),
922
1494
  };
923
1495
  logCanvasStateWarning('save state to sqlite failed', error, {});
1496
+ return false;
924
1497
  }
925
1498
  }
926
1499
 
@@ -933,11 +1506,9 @@ class CanvasStateManager {
933
1506
 
934
1507
  /** Close the SQLite database cleanly. Call on server shutdown. */
935
1508
  close(): void {
936
- if (this._saveTimer) {
937
- clearTimeout(this._saveTimer);
938
- this._saveTimer = null;
939
- this.saveToDisk();
940
- }
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();
941
1512
  if (this._db) {
942
1513
  try {
943
1514
  finalizeCanvasDbForClose(this._db);
@@ -951,6 +1522,8 @@ class CanvasStateManager {
951
1522
  }
952
1523
  this._db = null;
953
1524
  }
1525
+ this._activeBoardId = null;
1526
+ this._boardGeneration += 1;
954
1527
  }
955
1528
 
956
1529
  // ── Snapshots ───────────────────────────────────────────────
@@ -961,12 +1534,16 @@ class CanvasStateManager {
961
1534
  }
962
1535
 
963
1536
  private applyPersistedState(state: PersistedCanvasState): void {
1537
+ this._boardGeneration += 1;
964
1538
  this._tour = state.tour ? structuredClone(state.tour) : undefined;
965
1539
  this.nodes.clear();
966
1540
  this.edges.clear();
967
1541
  this.annotations.clear();
968
1542
  this._contextPinnedNodeIds.clear();
969
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 ?? []);
970
1547
 
971
1548
  this._viewport = {
972
1549
  x: state.viewport?.x ?? 0,
@@ -1004,10 +1581,16 @@ class CanvasStateManager {
1004
1581
  snapshot: CanvasSnapshot;
1005
1582
  state: PersistedCanvasState;
1006
1583
  } | null {
1584
+ if (!this._activeBoardId) return null;
1585
+
1007
1586
  // Try SQLite first
1008
1587
  if (this._db) {
1009
- const result = loadSnapshotFromDB(this._db, idOrName);
1588
+ const result = loadSnapshotFromDB(this._db, idOrName, this._activeBoardId);
1010
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;
1011
1594
  }
1012
1595
 
1013
1596
  // Fallback to filesystem (legacy on-disk snapshots are still readable in place)
@@ -1095,8 +1678,11 @@ class CanvasStateManager {
1095
1678
  annotations: Array.from(this.annotations.values()),
1096
1679
  contextPins: Array.from(this._contextPinnedNodeIds),
1097
1680
  ax: this.getAxState(),
1681
+ revisionState: this.revisionState(),
1098
1682
  });
1099
- saveSnapshotToDB(this._db, snapshot, { ...payload, tour: this._tour });
1683
+ const boardId = this.ensureActiveBoard();
1684
+ if (!boardId) return null;
1685
+ saveSnapshotToDB(this._db, boardId, snapshot, { ...payload, tour: this._tour });
1100
1686
  snapshot.nodeCount = payload.nodes.length;
1101
1687
  snapshot.edgeCount = payload.edges.length;
1102
1688
  return snapshot;
@@ -1110,7 +1696,7 @@ class CanvasStateManager {
1110
1696
  listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
1111
1697
  if (this._db) {
1112
1698
  try {
1113
- return listSnapshotsFromDB(this._db, options);
1699
+ return this._activeBoardId ? listSnapshotsFromDB(this._db, this._activeBoardId, options) : [];
1114
1700
  } catch (error) {
1115
1701
  logCanvasStateWarning('list snapshots from db failed', error, {});
1116
1702
  }
@@ -1189,6 +1775,7 @@ class CanvasStateManager {
1189
1775
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
1190
1776
  contextPins: Array.from(this._contextPinnedNodeIds),
1191
1777
  ax: this.getAxState(),
1778
+ revisionState: this.revisionState(),
1192
1779
  });
1193
1780
  const nextState: PersistedCanvasState = {
1194
1781
  version: 1,
@@ -1202,10 +1789,13 @@ class CanvasStateManager {
1202
1789
  : [],
1203
1790
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1204
1791
  ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
1792
+ revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
1205
1793
  };
1206
1794
 
1207
1795
  try {
1208
- this.applyPersistedState(nextState);
1796
+ const revisionBeforeRestore = this._contentRevision;
1797
+ this.applyPersistedState({ ...nextState, revisionState: this.revisionState() });
1798
+ this.restampRestoredContent(previousState.nodes, revisionBeforeRestore);
1209
1799
  this.scheduleSave();
1210
1800
  this.notifyChange('nodes');
1211
1801
  this.notifyChange('pins');
@@ -1214,14 +1804,20 @@ class CanvasStateManager {
1214
1804
  operationType: 'restoreSnapshot',
1215
1805
  description: `Restored snapshot "${resolved.snapshot.name}"`,
1216
1806
  forward: this.suppressed(() => {
1217
- 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);
1218
1811
  this.scheduleSave();
1219
1812
  this.notifyChange('nodes');
1220
1813
  this.notifyChange('pins');
1221
1814
  this.notifyChange('ax');
1222
1815
  }),
1223
1816
  inverse: this.suppressed(() => {
1224
- 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);
1225
1821
  this.scheduleSave();
1226
1822
  this.notifyChange('nodes');
1227
1823
  this.notifyChange('pins');
@@ -1268,9 +1864,9 @@ class CanvasStateManager {
1268
1864
 
1269
1865
  /** Delete a snapshot. */
1270
1866
  renameSnapshot(id: string, name: string): boolean {
1271
- if (!this._db) return false;
1867
+ if (!this._db || !this._activeBoardId) return false;
1272
1868
  try {
1273
- return renameSnapshotInDB(this._db, id, name);
1869
+ return renameSnapshotInDB(this._db, this._activeBoardId, id, name);
1274
1870
  } catch (error) {
1275
1871
  logCanvasStateWarning('rename snapshot failed', error, { id, name });
1276
1872
  return false;
@@ -1278,12 +1874,20 @@ class CanvasStateManager {
1278
1874
  }
1279
1875
 
1280
1876
  deleteSnapshot(id: string): boolean {
1877
+ if (!this._activeBoardId) return false;
1281
1878
  // Try SQLite first
1282
1879
  if (this._db) {
1283
1880
  try {
1284
- 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
+ }
1285
1888
  } catch (error) {
1286
1889
  logCanvasStateWarning('delete snapshot from db failed', error, { id });
1890
+ return false;
1287
1891
  }
1288
1892
  }
1289
1893
 
@@ -1320,12 +1924,77 @@ class CanvasStateManager {
1320
1924
 
1321
1925
  // ── Node CRUD ──────────────────────────────────────────────
1322
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
+
1323
1984
  get viewport(): ViewportState {
1324
1985
  return structuredClone(this._viewport);
1325
1986
  }
1326
1987
 
1327
1988
  addNode(node: CanvasNodeState): void {
1328
- 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
+ );
1329
1998
  this.nodes.set(node.id, cloned);
1330
1999
  this.scheduleSave();
1331
2000
  this.notifyChange('nodes');
@@ -1348,11 +2017,43 @@ class CanvasStateManager {
1348
2017
  updateNode(id: string, patch: Partial<CanvasNodeState>): void {
1349
2018
  const existing = this.nodes.get(id);
1350
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
+ }
1351
2032
  const oldSnapshot = structuredClone(existing);
1352
2033
  if (existing.type === 'group' && patch.position) {
1353
2034
  this.translateGroupChildren(id, patch.position.x - existing.position.x, patch.position.y - existing.position.y);
1354
2035
  }
1355
- 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
+ }
1356
2057
  this.nodes.set(id, nextNode);
1357
2058
  const parentGroupId = existing.data.parentGroup as string | undefined;
1358
2059
  if (parentGroupId) {
@@ -1370,7 +2071,16 @@ class CanvasStateManager {
1370
2071
  description: `Updated node "${(existing.data.title as string) ?? id}"`,
1371
2072
  forward: this.suppressed(() => this.updateNode(id, structuredClone(patch))),
1372
2073
  inverse: this.suppressed(() => {
1373
- 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);
1374
2084
  this.scheduleSave();
1375
2085
  this.notifyChange('nodes');
1376
2086
  }),
@@ -1391,6 +2101,7 @@ class CanvasStateManager {
1391
2101
  let released: string[] = [];
1392
2102
 
1393
2103
  if (existing) {
2104
+ this.recordDeletion(id);
1394
2105
  const parentGroupId = existing.data.parentGroup as string | undefined;
1395
2106
  const parent = parentGroupId ? this.nodes.get(parentGroupId) : undefined;
1396
2107
  const enclosing = parent && parent.type === 'group' ? parent : null;
@@ -1403,13 +2114,13 @@ class CanvasStateManager {
1403
2114
  const d = { ...child.data };
1404
2115
  if (enclosing) d.parentGroup = enclosing.id;
1405
2116
  else delete d.parentGroup;
1406
- this.nodes.set(cid, { ...child, data: d });
2117
+ this.setSemanticNode({ ...child, data: d });
1407
2118
  }
1408
2119
  }
1409
2120
  if (enclosing && enclosingBefore) {
1410
2121
  // The removed node leaves the enclosing group; a dissolved group's children take its place.
1411
2122
  const children = enclosingBefore.children.flatMap((cid) => (cid === id ? released : [cid]));
1412
- this.nodes.set(enclosing.id, { ...enclosing, data: { ...enclosing.data, children } });
2123
+ this.setSemanticNode({ ...enclosing, data: { ...enclosing.data, children } });
1413
2124
  }
1414
2125
  }
1415
2126
 
@@ -1463,15 +2174,17 @@ class CanvasStateManager {
1463
2174
  forward: this.suppressed(() => this.removeNode(id)),
1464
2175
  inverse: this.suppressed(() => {
1465
2176
  this.addNode(structuredClone(cloned));
2177
+ const restored = this.nodes.get(id);
2178
+ if (restored) this.nodes.set(id, { ...restored, createdBy: cloned.createdBy ?? unknownActor() });
1466
2179
  // A restored group takes its children back; the enclosing group's membership is restored verbatim.
1467
2180
  for (const cid of released) {
1468
2181
  const child = this.nodes.get(cid);
1469
- if (child) this.nodes.set(cid, { ...child, data: { ...child.data, parentGroup: id } });
2182
+ if (child) this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: id } });
1470
2183
  }
1471
2184
  if (enclosingBefore) {
1472
2185
  const enclosing = this.nodes.get(enclosingBefore.id);
1473
2186
  if (enclosing) {
1474
- this.nodes.set(enclosing.id, {
2187
+ this.setSemanticNode({
1475
2188
  ...enclosing,
1476
2189
  data: { ...enclosing.data, children: enclosingBefore.children },
1477
2190
  });
@@ -2132,6 +2845,21 @@ class CanvasStateManager {
2132
2845
  return this.ax.getAxTimelineSummary();
2133
2846
  }
2134
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
+
2135
2863
  getAxTimeline(q: AxTimelineQuery = {}): {
2136
2864
  events: PmxAxEvent[];
2137
2865
  evidence: PmxAxEvidence[];
@@ -2188,10 +2916,10 @@ class CanvasStateManager {
2188
2916
  }
2189
2917
 
2190
2918
  // Apply
2191
- this.nodes.set(groupId, { ...group, data: { ...group.data, children: merged } });
2919
+ this.setSemanticNode({ ...group, data: { ...group.data, children: merged } });
2192
2920
  for (const id of validIds) {
2193
2921
  const child = this.nodes.get(id)!;
2194
- this.nodes.set(id, { ...child, data: { ...child.data, parentGroup: groupId } });
2922
+ this.setSemanticNode({ ...child, data: { ...child.data, parentGroup: groupId } });
2195
2923
  }
2196
2924
  if (options.preservePositions === true) {
2197
2925
  if (options.keepGroupFrame !== true && group.data.frameMode !== 'manual') {
@@ -2214,14 +2942,14 @@ class CanvasStateManager {
2214
2942
  forward: this.suppressed(() => this.groupNodes(groupId, validIds, options)),
2215
2943
  inverse: this.suppressed(() => {
2216
2944
  const g = this.nodes.get(groupId);
2217
- if (g) this.nodes.set(groupId, { ...g, data: { ...g.data, children: oldChildren } });
2945
+ if (g) this.setSemanticNode({ ...g, data: { ...g.data, children: oldChildren } });
2218
2946
  for (const [id, oldParent] of oldParents) {
2219
2947
  const c = this.nodes.get(id);
2220
2948
  if (!c) continue;
2221
2949
  const d = { ...c.data };
2222
2950
  if (oldParent) d.parentGroup = oldParent;
2223
2951
  else delete d.parentGroup;
2224
- this.nodes.set(id, { ...c, data: d });
2952
+ this.setSemanticNode({ ...c, data: d });
2225
2953
  }
2226
2954
  this.scheduleSave();
2227
2955
  this.notifyChange('nodes');
@@ -2257,13 +2985,13 @@ class CanvasStateManager {
2257
2985
 
2258
2986
  const snapshot = childIds.slice();
2259
2987
 
2260
- this.nodes.set(groupId, { ...group, data: { ...group.data, children: [] } });
2988
+ this.setSemanticNode({ ...group, data: { ...group.data, children: [] } });
2261
2989
  for (const id of childIds) {
2262
2990
  const child = this.nodes.get(id);
2263
2991
  if (!child) continue;
2264
2992
  const d = { ...child.data };
2265
2993
  delete d.parentGroup;
2266
- this.nodes.set(id, { ...child, data: d });
2994
+ this.setSemanticNode({ ...child, data: d });
2267
2995
  }
2268
2996
 
2269
2997
  this.scheduleSave();
@@ -2286,6 +3014,7 @@ class CanvasStateManager {
2286
3014
  const oldPins = Array.from(this._contextPinnedNodeIds);
2287
3015
  const oldAxState = this.getAxState();
2288
3016
  const oldViewport = { ...this._viewport };
3017
+ for (const node of oldNodes) this.recordDeletion(node.id);
2289
3018
  this.nodes.clear();
2290
3019
  this.edges.clear();
2291
3020
  this.annotations.clear();