pmx-canvas 0.1.22 → 0.1.24

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 (53) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +140 -0
  3. package/Readme.md +40 -8
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  8. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  9. package/dist/types/client/types.d.ts +1 -0
  10. package/dist/types/json-render/catalog.d.ts +1 -1
  11. package/dist/types/mcp/canvas-access.d.ts +9 -0
  12. package/dist/types/server/ax-context.d.ts +3 -0
  13. package/dist/types/server/ax-state.d.ts +43 -0
  14. package/dist/types/server/canvas-db.d.ts +38 -0
  15. package/dist/types/server/canvas-state.d.ts +36 -16
  16. package/dist/types/server/index.d.ts +6 -0
  17. package/dist/types/server/mutation-history.d.ts +1 -1
  18. package/docs/cli.md +13 -0
  19. package/docs/http-api.md +24 -0
  20. package/docs/mcp.md +20 -2
  21. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  22. package/docs/screenshot.png +0 -0
  23. package/docs/sdk.md +5 -0
  24. package/package.json +3 -2
  25. package/skills/pmx-canvas/SKILL.md +22 -4
  26. package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
  27. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
  28. package/src/cli/agent.ts +34 -0
  29. package/src/cli/index.ts +2 -1
  30. package/src/client/App.tsx +2 -0
  31. package/src/client/canvas/CanvasNode.tsx +7 -0
  32. package/src/client/canvas/CommandPalette.tsx +2 -1
  33. package/src/client/canvas/use-node-drag.ts +29 -7
  34. package/src/client/canvas/use-node-resize.ts +27 -7
  35. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  36. package/src/client/nodes/HtmlNode.tsx +5 -2
  37. package/src/client/nodes/iframe-document-url.ts +58 -0
  38. package/src/client/state/intent-bridge.ts +8 -0
  39. package/src/client/state/sse-bridge.ts +2 -2
  40. package/src/client/theme/global.css +36 -3
  41. package/src/client/types.ts +1 -0
  42. package/src/mcp/canvas-access.ts +38 -0
  43. package/src/mcp/server.ts +113 -4
  44. package/src/server/ax-context.ts +38 -0
  45. package/src/server/ax-state.ts +130 -0
  46. package/src/server/canvas-db.ts +745 -0
  47. package/src/server/canvas-operations.ts +80 -1
  48. package/src/server/canvas-schema.ts +3 -3
  49. package/src/server/canvas-state.ts +390 -50
  50. package/src/server/canvas-validation.ts +6 -0
  51. package/src/server/index.ts +18 -0
  52. package/src/server/mutation-history.ts +1 -0
  53. package/src/server/server.ts +197 -11
@@ -5,15 +5,34 @@
5
5
  * - Agent tools (Phase 3) can read/mutate canvas state
6
6
  * - Client syncs bidirectionally (SSE for server→client, POST for client→server)
7
7
  *
8
- * Persistence: canvas state auto-saves to `.pmx-canvas/state.json` in the
9
- * workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
8
+ * Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
9
+ * in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
+ * Legacy `.pmx-canvas/state.json` is auto-migrated on first boot.
10
11
  */
11
12
 
12
13
  import { createHash } from 'node:crypto';
13
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, rmSync, unlinkSync } from 'node:fs';
14
15
  import { isAbsolute, join, dirname, relative } from 'node:path';
15
16
  import { gzipSync, gunzipSync } from 'node:zlib';
16
17
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
18
+ import {
19
+ openCanvasDb,
20
+ saveStateToDB,
21
+ loadStateFromDB,
22
+ saveSnapshotToDB,
23
+ loadSnapshotFromDB,
24
+ listSnapshotsFromDB,
25
+ deleteSnapshotFromDB,
26
+ writeBlobToDB,
27
+ readBlobFromDB,
28
+ hasBlobInDB,
29
+ isDbPopulated,
30
+ checkpointCanvasDb,
31
+ finalizeCanvasDbForClose,
32
+ type PersistedCanvasState,
33
+ type CanvasTheme,
34
+ } from './canvas-db.js';
35
+ import { normalizeCanvasTheme } from './canvas-db.js';
17
36
  import {
18
37
  type CanvasPlacementRect,
19
38
  computeGroupBounds,
@@ -22,6 +41,13 @@ import {
22
41
  GROUP_TITLEBAR_HEIGHT,
23
42
  resolveGroupCollision,
24
43
  } from './placement.js';
44
+ import {
45
+ createEmptyAxState,
46
+ normalizeAxState,
47
+ type PmxAxFocusState,
48
+ type PmxAxSource,
49
+ type PmxAxState,
50
+ } from './ax-state.js';
25
51
 
26
52
  function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
27
53
  console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
@@ -40,6 +66,7 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
40
66
 
41
67
  export const PMX_CANVAS_DIR = '.pmx-canvas';
42
68
  const STATE_FILENAME = 'state.json';
69
+ const DB_FILENAME = 'canvas.db';
43
70
  const SNAPSHOTS_SUBDIR = 'snapshots';
44
71
  const BLOBS_SUBDIR = 'blobs';
45
72
  const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
@@ -65,14 +92,8 @@ export interface PersistedBlobRef {
65
92
  jsonBytes: number;
66
93
  }
67
94
 
68
- interface PersistedCanvasState {
69
- version: number;
70
- viewport: ViewportState;
71
- nodes: CanvasNodeState[];
72
- edges: CanvasEdge[];
73
- annotations?: CanvasAnnotation[];
74
- contextPins: string[];
75
- }
95
+ // Re-export for backward compat — canonical definition is now in canvas-db.ts
96
+ export type { PersistedCanvasState } from './canvas-db.js';
76
97
 
77
98
  interface LoadFromDiskOptions {
78
99
  clearExisting?: boolean;
@@ -174,6 +195,7 @@ export interface CanvasAnnotation {
174
195
 
175
196
  export interface CanvasLayout {
176
197
  viewport: ViewportState;
198
+ theme: CanvasTheme;
177
199
  nodes: CanvasNodeState[];
178
200
  edges: CanvasEdge[];
179
201
  annotations: CanvasAnnotation[];
@@ -187,10 +209,10 @@ export interface CanvasNodeUpdate {
187
209
  dockPosition?: 'left' | 'right' | null;
188
210
  }
189
211
 
190
- export type CanvasChangeType = 'pins' | 'nodes';
212
+ export type CanvasChangeType = 'pins' | 'nodes' | 'ax';
191
213
 
192
214
  export interface MutationRecordInfo {
193
- operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
215
+ operationType: 'addNode' | 'updateNode' | 'removeNode' | 'addEdge' | 'removeEdge' | 'addAnnotation' | 'removeAnnotation' | 'clear' | 'restoreSnapshot' | 'setPins' | 'setAxFocus' | 'arrange' | 'batch' | 'groupNodes' | 'ungroupNodes' | 'viewport';
194
216
  description: string;
195
217
  forward: () => void;
196
218
  inverse: () => void;
@@ -248,7 +270,9 @@ class CanvasStateManager {
248
270
  private edges = new Map<string, CanvasEdge>();
249
271
  private annotations = new Map<string, CanvasAnnotation>();
250
272
  private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
273
+ private _theme: CanvasTheme = 'dark';
251
274
  private _contextPinnedNodeIds = new Set<string>();
275
+ private _axState: PmxAxState = createEmptyAxState();
252
276
  private _workspaceRoot = process.cwd();
253
277
 
254
278
  // ── Change listeners (for MCP resource notifications) ──────
@@ -298,6 +322,18 @@ class CanvasStateManager {
298
322
  }
299
323
  }
300
324
 
325
+ private currentNodeIdSet(): Set<string> {
326
+ return new Set(this.nodes.keys());
327
+ }
328
+
329
+ private normalizeAxForCurrentNodes(state: unknown): PmxAxState {
330
+ return normalizeAxState(state, this.currentNodeIdSet());
331
+ }
332
+
333
+ private applyAxState(state: PmxAxState): void {
334
+ this._axState = this.normalizeAxForCurrentNodes(state);
335
+ }
336
+
301
337
  private applyResolvedGroupBounds(
302
338
  group: CanvasNodeState,
303
339
  groupId: string,
@@ -481,14 +517,38 @@ class CanvasStateManager {
481
517
 
482
518
  // ── Persistence ────────────────────────────────────────────
483
519
  private _stateFilePath: string | null = null;
520
+ private _db: import('bun:sqlite').Database | null = null;
484
521
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
485
522
 
486
523
  /** Set the workspace root to enable auto-persistence. */
487
524
  setWorkspaceRoot(workspaceRoot: string): void {
525
+ this.close();
488
526
  this._workspaceRoot = workspaceRoot;
489
527
  this.migrateLegacyLayout(workspaceRoot);
490
- const override = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
491
- this._stateFilePath = override || join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
528
+
529
+ // Determine DB path
530
+ const dbOverride = (process.env.PMX_CANVAS_DB_PATH ?? '').trim();
531
+ const stateFileOverride = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
532
+ let dbPath: string;
533
+ if (dbOverride) {
534
+ dbPath = dbOverride;
535
+ } else if (stateFileOverride && stateFileOverride.endsWith('.db')) {
536
+ dbPath = stateFileOverride;
537
+ } else {
538
+ dbPath = join(workspaceRoot, PMX_CANVAS_DIR, DB_FILENAME);
539
+ }
540
+
541
+ // Keep legacy _stateFilePath for JSON migration detection
542
+ this._stateFilePath = stateFileOverride && !stateFileOverride.endsWith('.db')
543
+ ? stateFileOverride
544
+ : join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
545
+
546
+ try {
547
+ this._db = openCanvasDb(dbPath);
548
+ this.migrateJsonToSqlite();
549
+ } catch (error) {
550
+ logCanvasStateWarning('open canvas database failed', error, { dbPath });
551
+ }
492
552
  }
493
553
 
494
554
  private get blobsDir(): string | null {
@@ -512,13 +572,33 @@ class CanvasStateManager {
512
572
  }
513
573
 
514
574
  private writeBlobValue(value: unknown): PersistedBlobRef | null {
515
- const dir = this.blobsDir;
516
- if (!dir) return null;
517
575
  const json = JSON.stringify(value);
518
576
  if (typeof json !== 'string') return null;
519
577
  const jsonBytes = Buffer.byteLength(json);
520
578
  if (jsonBytes < BLOB_JSON_THRESHOLD_BYTES) return null;
521
579
  const sha256 = createHash('sha256').update(json).digest('hex');
580
+
581
+ // Write to SQLite if DB is available
582
+ if (this._db) {
583
+ try {
584
+ const bytes = writeBlobToDB(this._db, sha256, json);
585
+ return {
586
+ __pmxCanvasBlob: 'v1',
587
+ path: `blobs/${sha256}`,
588
+ sha256,
589
+ encoding: 'json+gzip',
590
+ bytes,
591
+ jsonBytes,
592
+ };
593
+ } catch (error) {
594
+ logCanvasStateWarning('write blob to db failed', error, { sha256 });
595
+ return null;
596
+ }
597
+ }
598
+
599
+ // Fallback to filesystem (for when DB is not yet initialized)
600
+ const dir = this.blobsDir;
601
+ if (!dir) return null;
522
602
  const prefix = sha256.slice(0, 2);
523
603
  const filePath = join(dir, prefix, `${sha256}.json.gz`);
524
604
  try {
@@ -541,6 +621,24 @@ class CanvasStateManager {
541
621
  }
542
622
 
543
623
  private readBlobValue(ref: PersistedBlobRef): unknown {
624
+ // Try SQLite first
625
+ if (this._db) {
626
+ try {
627
+ const json = readBlobFromDB(this._db, ref.sha256);
628
+ if (json) {
629
+ const sha256 = createHash('sha256').update(json).digest('hex');
630
+ if (sha256 !== ref.sha256) {
631
+ logCanvasStateWarning('blob checksum mismatch (db)', 'checksum mismatch', { sha256: ref.sha256 });
632
+ return ref;
633
+ }
634
+ return JSON.parse(json) as unknown;
635
+ }
636
+ } catch (error) {
637
+ logCanvasStateWarning('read blob from db failed', error, { sha256: ref.sha256 });
638
+ }
639
+ }
640
+
641
+ // Fallback to filesystem (for legacy blobs not yet migrated)
544
642
  const filePath = this.resolveBlobPath(ref);
545
643
  if (!filePath) return ref;
546
644
  try {
@@ -629,6 +727,84 @@ class CanvasStateManager {
629
727
  }
630
728
  }
631
729
 
730
+ /**
731
+ * One-time migration: import state.json + snapshot JSON files + blob files
732
+ * into the SQLite database. Renames originals to `.bak`.
733
+ */
734
+ private migrateJsonToSqlite(): void {
735
+ if (!this._db || !this._stateFilePath) return;
736
+ const db = this._db;
737
+
738
+ if (isDbPopulated(this._db)) return; // DB already initialized
739
+
740
+ if (existsSync(this._stateFilePath)) {
741
+ try {
742
+ const raw = readFileSync(this._stateFilePath, 'utf-8');
743
+ const parsed = JSON.parse(raw) as PersistedCanvasState;
744
+ if (parsed && parsed.version === 1) {
745
+ saveStateToDB(db, parsed);
746
+ renameSync(this._stateFilePath, `${this._stateFilePath}.bak`);
747
+ }
748
+ } catch (error) {
749
+ logCanvasStateWarning('migrate state.json to sqlite failed', error, {
750
+ path: this._stateFilePath,
751
+ });
752
+ }
753
+ }
754
+
755
+ // Migrate snapshot JSON files
756
+ const snapshotsDir = this.snapshotsDir;
757
+ if (snapshotsDir && existsSync(snapshotsDir)) {
758
+ try {
759
+ const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json'));
760
+ for (const file of files) {
761
+ try {
762
+ const filePath = join(snapshotsDir, file);
763
+ const raw = readFileSync(filePath, 'utf-8');
764
+ const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
765
+ if (parsed.snapshot && parsed.version === 1) {
766
+ saveSnapshotToDB(db, parsed.snapshot, parsed);
767
+ renameSync(filePath, `${filePath}.bak`);
768
+ }
769
+ } catch (error) {
770
+ logCanvasStateWarning('migrate snapshot file to sqlite failed', error, { file });
771
+ }
772
+ }
773
+ } catch (error) {
774
+ logCanvasStateWarning('migrate snapshots dir failed', error, { snapshotsDir });
775
+ }
776
+ }
777
+
778
+ // Migrate blob files
779
+ const blobsDir = this.blobsDir;
780
+ if (blobsDir && existsSync(blobsDir)) {
781
+ try {
782
+ const prefixes = readdirSync(blobsDir).filter((d) => d.length === 2);
783
+ for (const prefix of prefixes) {
784
+ const prefixDir = join(blobsDir, prefix);
785
+ const blobFiles = readdirSync(prefixDir).filter((f) => f.endsWith('.json.gz'));
786
+ for (const blobFile of blobFiles) {
787
+ try {
788
+ const blobPath = join(prefixDir, blobFile);
789
+ const sha256 = blobFile.replace('.json.gz', '');
790
+ if (!hasBlobInDB(db, sha256)) {
791
+ const compressed = readFileSync(blobPath);
792
+ const json = gunzipSync(compressed).toString('utf-8');
793
+ writeBlobToDB(db, sha256, json);
794
+ }
795
+ const backupPath = `${blobPath}.bak`;
796
+ if (!existsSync(backupPath)) renameSync(blobPath, backupPath);
797
+ } catch (error) {
798
+ logCanvasStateWarning('migrate blob file to sqlite failed', error, { blobFile });
799
+ }
800
+ }
801
+ }
802
+ } catch (error) {
803
+ logCanvasStateWarning('migrate blobs dir failed', error, { blobsDir });
804
+ }
805
+ }
806
+ }
807
+
632
808
  getWorkspaceRoot(): string {
633
809
  return this._workspaceRoot;
634
810
  }
@@ -636,39 +812,55 @@ class CanvasStateManager {
636
812
  private emptyPersistedState(): PersistedCanvasState {
637
813
  return {
638
814
  version: 1,
815
+ theme: this._theme,
639
816
  viewport: { x: 0, y: 0, scale: 1 },
640
817
  nodes: [],
641
818
  edges: [],
642
819
  annotations: [],
643
820
  contextPins: [],
821
+ ax: createEmptyAxState(),
644
822
  };
645
823
  }
646
824
 
647
- /** Load canvas state from disk. Call once on server startup. */
825
+ /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
648
826
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
649
- if (!this._stateFilePath || !existsSync(this._stateFilePath)) {
650
- if (options.clearExisting) {
651
- this.applyPersistedState(this.emptyPersistedState());
827
+ // Try SQLite first (only if DB has been populated)
828
+ if (this._db && isDbPopulated(this._db)) {
829
+ try {
830
+ const state = loadStateFromDB(this._db);
831
+ if (state) {
832
+ this.applyPersistedState(state);
833
+ return true;
834
+ }
835
+ } catch (error) {
836
+ logCanvasStateWarning('load state from sqlite failed', error, {});
652
837
  }
653
- return false;
654
838
  }
655
- try {
656
- const raw = readFileSync(this._stateFilePath, 'utf-8');
657
- const parsed = JSON.parse(raw) as PersistedCanvasState;
658
- if (!parsed || parsed.version !== 1) return false;
659
- this.applyPersistedState(parsed);
660
- return true;
661
- } catch (error) {
662
- logCanvasStateWarning('load state from disk failed', error, {
663
- path: this._stateFilePath ?? undefined,
664
- });
665
- return false;
839
+
840
+ // Fallback to JSON (for edge cases where migration hasn't happened)
841
+ if (this._stateFilePath && existsSync(this._stateFilePath)) {
842
+ try {
843
+ const raw = readFileSync(this._stateFilePath, 'utf-8');
844
+ const parsed = JSON.parse(raw) as PersistedCanvasState;
845
+ if (!parsed || parsed.version !== 1) return false;
846
+ this.applyPersistedState(parsed);
847
+ return true;
848
+ } catch (error) {
849
+ logCanvasStateWarning('load state from json fallback failed', error, {
850
+ path: this._stateFilePath,
851
+ });
852
+ }
666
853
  }
854
+
855
+ if (options.clearExisting) {
856
+ this.applyPersistedState(this.emptyPersistedState());
857
+ }
858
+ return false;
667
859
  }
668
860
 
669
- /** Debounced save — coalesces rapid mutations into a single disk write. */
861
+ /** Debounced save — coalesces rapid mutations into a single write. */
670
862
  private scheduleSave(): void {
671
- if (!this._stateFilePath) return;
863
+ if (!this._db) return;
672
864
  if (this._saveTimer) clearTimeout(this._saveTimer);
673
865
  this._saveTimer = setTimeout(() => {
674
866
  this._saveTimer = null;
@@ -682,28 +874,54 @@ class CanvasStateManager {
682
874
  this._saveTimer = null;
683
875
  }
684
876
  this.saveToDisk();
877
+ if (this._db) {
878
+ try {
879
+ checkpointCanvasDb(this._db);
880
+ } catch (error) {
881
+ logCanvasStateWarning('checkpoint database failed', error, {});
882
+ }
883
+ }
685
884
  }
686
885
 
687
- /** Write current state to disk immediately. */
886
+ /** Write current state to SQLite immediately. */
688
887
  private saveToDisk(): void {
689
- if (!this._stateFilePath) return;
888
+ if (!this._db) return;
690
889
  try {
691
- const dir = dirname(this._stateFilePath);
692
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
693
-
694
890
  const payload = this.externalizePersistedStateBlobs({
695
891
  version: 1,
892
+ theme: this._theme,
696
893
  viewport: this._viewport,
697
894
  nodes: Array.from(this.nodes.values()),
698
895
  edges: Array.from(this.edges.values()),
699
896
  annotations: Array.from(this.annotations.values()),
700
897
  contextPins: Array.from(this._contextPinnedNodeIds),
898
+ ax: this.getAxState(),
701
899
  });
702
- writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
900
+ saveStateToDB(this._db, payload);
703
901
  } catch (error) {
704
- logCanvasStateWarning('save state to disk failed', error, {
705
- path: this._stateFilePath ?? undefined,
706
- });
902
+ logCanvasStateWarning('save state to sqlite failed', error, {});
903
+ }
904
+ }
905
+
906
+ /** Close the SQLite database cleanly. Call on server shutdown. */
907
+ close(): void {
908
+ if (this._saveTimer) {
909
+ clearTimeout(this._saveTimer);
910
+ this._saveTimer = null;
911
+ this.saveToDisk();
912
+ }
913
+ if (this._db) {
914
+ try {
915
+ finalizeCanvasDbForClose(this._db);
916
+ } catch (error) {
917
+ logCanvasStateWarning('finalize database failed', error, {});
918
+ }
919
+ try {
920
+ this._db.close();
921
+ } catch (error) {
922
+ logCanvasStateWarning('close database failed', error, {});
923
+ }
924
+ this._db = null;
707
925
  }
708
926
  }
709
927
 
@@ -719,12 +937,14 @@ class CanvasStateManager {
719
937
  this.edges.clear();
720
938
  this.annotations.clear();
721
939
  this._contextPinnedNodeIds.clear();
940
+ this._axState = createEmptyAxState();
722
941
 
723
942
  this._viewport = {
724
943
  x: state.viewport?.x ?? 0,
725
944
  y: state.viewport?.y ?? 0,
726
945
  scale: state.viewport?.scale ?? 1,
727
946
  };
947
+ this._theme = normalizeCanvasTheme(state.theme, this._theme);
728
948
 
729
949
  if (Array.isArray(state.nodes)) {
730
950
  for (const node of state.nodes) {
@@ -748,12 +968,20 @@ class CanvasStateManager {
748
968
  if (this.nodes.has(pinId)) this._contextPinnedNodeIds.add(pinId);
749
969
  }
750
970
  }
971
+ this._axState = this.normalizeAxForCurrentNodes(state.ax);
751
972
  }
752
973
 
753
974
  private readResolvedSnapshot(idOrName: string): {
754
975
  snapshot: CanvasSnapshot;
755
976
  state: PersistedCanvasState;
756
977
  } | null {
978
+ // Try SQLite first
979
+ if (this._db) {
980
+ const result = loadSnapshotFromDB(this._db, idOrName);
981
+ if (result) return result;
982
+ }
983
+
984
+ // Fallback to filesystem (for legacy snapshots not yet migrated)
757
985
  const dir = this.snapshotsDir;
758
986
  if (!dir || !existsSync(dir)) return null;
759
987
 
@@ -817,8 +1045,7 @@ class CanvasStateManager {
817
1045
 
818
1046
  /** Save current canvas state as a named snapshot. */
819
1047
  saveSnapshot(name: string): CanvasSnapshot | null {
820
- const dir = this.snapshotsDir;
821
- if (!dir) return null;
1048
+ if (!this._db) return null;
822
1049
 
823
1050
  const id = `snap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
824
1051
  const snapshot: CanvasSnapshot = {
@@ -830,18 +1057,17 @@ class CanvasStateManager {
830
1057
  };
831
1058
 
832
1059
  try {
833
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
834
-
835
1060
  const payload = this.externalizePersistedStateBlobs({
836
1061
  version: 1,
837
- snapshot,
1062
+ theme: this._theme,
838
1063
  viewport: this._viewport,
839
1064
  nodes: Array.from(this.nodes.values()),
840
1065
  edges: Array.from(this.edges.values()),
841
1066
  annotations: Array.from(this.annotations.values()),
842
1067
  contextPins: Array.from(this._contextPinnedNodeIds),
1068
+ ax: this.getAxState(),
843
1069
  });
844
- writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
1070
+ saveSnapshotToDB(this._db, snapshot, payload);
845
1071
  snapshot.nodeCount = payload.nodes.length;
846
1072
  snapshot.edgeCount = payload.edges.length;
847
1073
  return snapshot;
@@ -853,6 +1079,15 @@ class CanvasStateManager {
853
1079
 
854
1080
  /** List saved snapshots, newest first. */
855
1081
  listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
1082
+ if (this._db) {
1083
+ try {
1084
+ return listSnapshotsFromDB(this._db, options);
1085
+ } catch (error) {
1086
+ logCanvasStateWarning('list snapshots from db failed', error, {});
1087
+ }
1088
+ }
1089
+
1090
+ // Fallback to filesystem
856
1091
  const dir = this.snapshotsDir;
857
1092
  if (!dir || !existsSync(dir)) return [];
858
1093
 
@@ -915,19 +1150,23 @@ class CanvasStateManager {
915
1150
 
916
1151
  const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
917
1152
  version: 1,
1153
+ theme: this._theme,
918
1154
  viewport: structuredClone(this._viewport),
919
1155
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
920
1156
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
921
1157
  annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
922
1158
  contextPins: Array.from(this._contextPinnedNodeIds),
1159
+ ax: this.getAxState(),
923
1160
  });
924
1161
  const nextState: PersistedCanvasState = {
925
1162
  version: 1,
1163
+ theme: normalizeCanvasTheme(resolved.state.theme, this._theme),
926
1164
  viewport: structuredClone(resolved.state.viewport),
927
1165
  nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
928
1166
  edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
929
1167
  annotations: Array.isArray(resolved.state.annotations) ? resolved.state.annotations.map((annotation) => structuredClone(annotation)) : [],
930
1168
  contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
1169
+ ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
931
1170
  };
932
1171
 
933
1172
  try {
@@ -935,6 +1174,7 @@ class CanvasStateManager {
935
1174
  this.scheduleSave();
936
1175
  this.notifyChange('nodes');
937
1176
  this.notifyChange('pins');
1177
+ this.notifyChange('ax');
938
1178
  this.recordMutation({
939
1179
  operationType: 'restoreSnapshot',
940
1180
  description: `Restored snapshot "${resolved.snapshot.name}"`,
@@ -943,12 +1183,14 @@ class CanvasStateManager {
943
1183
  this.scheduleSave();
944
1184
  this.notifyChange('nodes');
945
1185
  this.notifyChange('pins');
1186
+ this.notifyChange('ax');
946
1187
  }),
947
1188
  inverse: this.suppressed(() => {
948
1189
  this.applyPersistedState(previousState);
949
1190
  this.scheduleSave();
950
1191
  this.notifyChange('nodes');
951
1192
  this.notifyChange('pins');
1193
+ this.notifyChange('ax');
952
1194
  }),
953
1195
  });
954
1196
  return true;
@@ -982,6 +1224,16 @@ class CanvasStateManager {
982
1224
 
983
1225
  /** Delete a snapshot. */
984
1226
  deleteSnapshot(id: string): boolean {
1227
+ // Try SQLite first
1228
+ if (this._db) {
1229
+ try {
1230
+ if (deleteSnapshotFromDB(this._db, id)) return true;
1231
+ } catch (error) {
1232
+ logCanvasStateWarning('delete snapshot from db failed', error, { id });
1233
+ }
1234
+ }
1235
+
1236
+ // Fallback to filesystem
985
1237
  const dir = this.snapshotsDir;
986
1238
  if (!dir) return false;
987
1239
  const filePath = join(dir, `${id}.json`);
@@ -995,6 +1247,23 @@ class CanvasStateManager {
995
1247
  }
996
1248
  }
997
1249
 
1250
+ /** Remove all snapshots from the DB. Used by test teardown. */
1251
+ clearAllSnapshots(): void {
1252
+ if (this._db) {
1253
+ this._db.run('DELETE FROM snapshots');
1254
+ this._db.run('DELETE FROM snapshot_nodes');
1255
+ this._db.run('DELETE FROM snapshot_edges');
1256
+ this._db.run('DELETE FROM snapshot_annotations');
1257
+ this._db.run('DELETE FROM snapshot_pins');
1258
+ this._db.run('DELETE FROM snapshot_meta');
1259
+ }
1260
+ // Also clear filesystem snapshots dir
1261
+ const dir = this.snapshotsDir;
1262
+ if (dir && existsSync(dir)) {
1263
+ rmSync(dir, { recursive: true, force: true });
1264
+ }
1265
+ }
1266
+
998
1267
  // ── Node CRUD ──────────────────────────────────────────────
999
1268
 
1000
1269
  get viewport(): ViewportState {
@@ -1058,6 +1327,7 @@ class CanvasStateManager {
1058
1327
  const existing = this.nodes.get(id);
1059
1328
  const connectedEdges = existing ? this.getEdgesForNode(id).map((e) => structuredClone(e)) : [];
1060
1329
  const cloned = existing ? structuredClone(existing) : null;
1330
+ const oldAxState = this.getAxState();
1061
1331
 
1062
1332
  // Prune from parent group's children list
1063
1333
  if (existing) {
@@ -1086,9 +1356,11 @@ class CanvasStateManager {
1086
1356
  this.nodes.delete(id);
1087
1357
  this.removeEdgesForNode(id);
1088
1358
  this._contextPinnedNodeIds.delete(id);
1359
+ this.applyAxState(this._axState);
1089
1360
  this.scheduleSave();
1090
1361
  this.notifyChange('nodes');
1091
1362
  this.notifyChange('pins');
1363
+ this.notifyChange('ax');
1092
1364
  if (cloned) {
1093
1365
  this.recordMutation({
1094
1366
  operationType: 'removeNode',
@@ -1097,6 +1369,9 @@ class CanvasStateManager {
1097
1369
  inverse: this.suppressed(() => {
1098
1370
  this.addNode(structuredClone(cloned));
1099
1371
  for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
1372
+ this.applyAxState(oldAxState);
1373
+ this.scheduleSave();
1374
+ this.notifyChange('ax');
1100
1375
  }),
1101
1376
  });
1102
1377
  }
@@ -1206,6 +1481,7 @@ class CanvasStateManager {
1206
1481
  getLayout(): CanvasLayout {
1207
1482
  return {
1208
1483
  viewport: structuredClone(this._viewport),
1484
+ theme: this._theme,
1209
1485
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
1210
1486
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1211
1487
  annotations: this.getAnnotations(),
@@ -1215,6 +1491,7 @@ class CanvasStateManager {
1215
1491
  getLayoutForPersistence(): CanvasLayout {
1216
1492
  return {
1217
1493
  viewport: structuredClone(this._viewport),
1494
+ theme: this._theme,
1218
1495
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
1219
1496
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1220
1497
  annotations: this.getAnnotations(),
@@ -1334,12 +1611,70 @@ class CanvasStateManager {
1334
1611
  });
1335
1612
  }
1336
1613
 
1614
+ get theme(): CanvasTheme {
1615
+ return this._theme;
1616
+ }
1617
+
1618
+ setTheme(theme: CanvasTheme): CanvasTheme {
1619
+ const next = normalizeCanvasTheme(theme, this._theme);
1620
+ if (next === this._theme) return this._theme;
1621
+ this._theme = next;
1622
+ this.scheduleSave();
1623
+ this.notifyChange('nodes');
1624
+ return this._theme;
1625
+ }
1626
+
1337
1627
  // ── Context pins ─────────────────────────────────────────────
1338
1628
 
1339
1629
  get contextPinnedNodeIds(): Set<string> {
1340
1630
  return new Set(this._contextPinnedNodeIds);
1341
1631
  }
1342
1632
 
1633
+ getAxState(): PmxAxState {
1634
+ return structuredClone(this.normalizeAxForCurrentNodes(this._axState));
1635
+ }
1636
+
1637
+ getAxFocus(): PmxAxFocusState {
1638
+ return this.getAxState().focus;
1639
+ }
1640
+
1641
+ setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
1642
+ const oldAxState = this.getAxState();
1643
+ const nextAxState: PmxAxState = {
1644
+ ...oldAxState,
1645
+ focus: {
1646
+ nodeIds,
1647
+ primaryNodeId: nodeIds[0] ?? null,
1648
+ updatedAt: new Date().toISOString(),
1649
+ source: options.source ?? 'api',
1650
+ },
1651
+ };
1652
+ this.applyAxState(nextAxState);
1653
+ const appliedAxState = this.getAxState();
1654
+ this.scheduleSave();
1655
+ this.notifyChange('ax');
1656
+ if (options.recordHistory === false) return appliedAxState.focus;
1657
+ this.recordMutation({
1658
+ operationType: 'setAxFocus',
1659
+ description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
1660
+ forward: this.suppressed(() => {
1661
+ this.applyAxState(appliedAxState);
1662
+ this.scheduleSave();
1663
+ this.notifyChange('ax');
1664
+ }),
1665
+ inverse: this.suppressed(() => {
1666
+ this.applyAxState(oldAxState);
1667
+ this.scheduleSave();
1668
+ this.notifyChange('ax');
1669
+ }),
1670
+ });
1671
+ return appliedAxState.focus;
1672
+ }
1673
+
1674
+ clearAxFocus(): PmxAxFocusState {
1675
+ return this.setAxFocus([], { source: 'system' });
1676
+ }
1677
+
1343
1678
  setContextPins(nodeIds: string[]): void {
1344
1679
  const oldPins = Array.from(this._contextPinnedNodeIds);
1345
1680
  this._contextPinnedNodeIds.clear();
@@ -1465,15 +1800,18 @@ class CanvasStateManager {
1465
1800
  const oldEdges = Array.from(this.edges.values()).map((e) => structuredClone(e));
1466
1801
  const oldAnnotations = Array.from(this.annotations.values()).map((annotation) => structuredClone(annotation));
1467
1802
  const oldPins = Array.from(this._contextPinnedNodeIds);
1803
+ const oldAxState = this.getAxState();
1468
1804
  const oldViewport = { ...this._viewport };
1469
1805
  this.nodes.clear();
1470
1806
  this.edges.clear();
1471
1807
  this.annotations.clear();
1472
1808
  this._contextPinnedNodeIds.clear();
1809
+ this._axState = createEmptyAxState();
1473
1810
  this._viewport = { x: 0, y: 0, scale: 1 };
1474
1811
  this.scheduleSave();
1475
1812
  this.notifyChange('nodes');
1476
1813
  this.notifyChange('pins');
1814
+ this.notifyChange('ax');
1477
1815
  this.recordMutation({
1478
1816
  operationType: 'clear',
1479
1817
  description: `Cleared canvas (was ${oldNodes.length} nodes, ${oldEdges.length} edges)`,
@@ -1483,7 +1821,9 @@ class CanvasStateManager {
1483
1821
  for (const e of oldEdges) this.addEdge(structuredClone(e));
1484
1822
  for (const annotation of oldAnnotations) this.addAnnotation(structuredClone(annotation));
1485
1823
  this.setContextPins(oldPins);
1824
+ this.applyAxState(oldAxState);
1486
1825
  this.setViewport(oldViewport);
1826
+ this.notifyChange('ax');
1487
1827
  }),
1488
1828
  });
1489
1829
  }