pmx-canvas 0.1.21 → 0.1.23

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.
@@ -19,7 +19,7 @@ import {
19
19
  import { mutationHistory } from './mutation-history.js';
20
20
  import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
21
21
  import { searchNodes } from './spatial-analysis.js';
22
- import { getCanvasNodeTitle, serializeCanvasNode, type SerializedCanvasNode } from './canvas-serialization.js';
22
+ import { getCanvasNodeTitle, serializeCanvasNodeCompact, type SerializedCanvasNode } from './canvas-serialization.js';
23
23
  import { computeAutoArrange } from '../shared/auto-arrange.js';
24
24
  import {
25
25
  buildGraphSpec,
@@ -1032,7 +1032,7 @@ export function removeCanvasNode(id: string): {
1032
1032
  }
1033
1033
 
1034
1034
  function isArrangeLocked(node: CanvasNodeState): boolean {
1035
- return node.pinned || node.data.arrangeLocked === true;
1035
+ return node.pinned || node.dockPosition !== null || node.data.arrangeLocked === true;
1036
1036
  }
1037
1037
 
1038
1038
  function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
@@ -1490,7 +1490,7 @@ function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknow
1490
1490
  }
1491
1491
 
1492
1492
  function serializeCreatedNode(node: CanvasNodeState): SerializedCanvasNode {
1493
- return serializeCanvasNode(node);
1493
+ return serializeCanvasNodeCompact(node);
1494
1494
  }
1495
1495
 
1496
1496
  export async function executeCanvasBatch(
@@ -1527,10 +1527,15 @@ export async function executeCanvasBatch(
1527
1527
  throw new Error('Batch html-primitive creation is not supported yet. Use node.add with type "html" and generated html, or create the primitive through MCP/HTTP/CLI first.');
1528
1528
  }
1529
1529
  if (type === 'webpage') {
1530
+ const content = typeof args.url === 'string' && args.url.trim().length > 0
1531
+ ? args.url
1532
+ : typeof args.content === 'string'
1533
+ ? args.content
1534
+ : undefined;
1530
1535
  const created = addCanvasNode({
1531
1536
  type: 'webpage',
1532
1537
  ...(typeof args.title === 'string' ? { title: args.title } : {}),
1533
- ...(typeof args.content === 'string' ? { content: args.content } : {}),
1538
+ ...(content ? { content } : {}),
1534
1539
  ...(isPlainRecord(args.data) ? { data: args.data } : {}),
1535
1540
  ...(typeof args.x === 'number' ? { x: args.x } : {}),
1536
1541
  ...(typeof args.y === 'number' ? { y: args.y } : {}),
@@ -244,11 +244,11 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
244
244
  fields: [
245
245
  { name: 'html', type: 'string', required: false, description: 'HTML document or fragment rendered in the sandboxed iframe.', aliases: ['content', 'stdin'] },
246
246
  { name: 'summary', type: 'string', required: false, description: 'Explicit agent-readable summary. If omitted, PMX derives one from visible HTML text.' },
247
- { name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.' },
248
- { name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.' },
249
- { name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.' },
247
+ { name: 'agentSummary', type: 'string', required: false, description: 'Explicit semantic sidecar used by search, pinned context, and spatial context.', aliases: ['agent-summary'] },
248
+ { name: 'embeddedNodeIds', type: 'string[]', required: false, description: 'Canvas node IDs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-node-id', 'embedded-node-ids'] },
249
+ { name: 'embeddedUrls', type: 'string[]', required: false, description: 'URLs represented or iframe-embedded by this HTML surface.', aliases: ['embedded-url', 'embedded-urls'] },
250
250
  { name: 'presentation', type: 'boolean', required: false, description: 'Marks this HTML surface as a fullscreen presentation/deck.' },
251
- { name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.' },
251
+ { name: 'slideTitles', type: 'string[]', required: false, description: 'Agent-readable slide titles for presentation HTML.', aliases: ['slide-title', 'slide-titles'] },
252
252
  { name: 'primitive', type: 'HtmlPrimitiveKind', required: false, description: 'Generate HTML from a built-in communication primitive instead of passing raw HTML.', aliases: ['kind'] },
253
253
  { name: 'data', type: 'record<string, unknown>', required: false, description: 'Primitive data when --primitive is used, or arbitrary node metadata.' },
254
254
  { name: 'title', type: 'string', required: false, description: 'Optional node title.' },
@@ -56,6 +56,13 @@ interface ExternalMcpAppHtmlSummary {
56
56
  sha256: string;
57
57
  }
58
58
 
59
+ interface FileContentSummary {
60
+ omitted: 'file-content';
61
+ bytes: number;
62
+ lineCount: number;
63
+ sha256: string;
64
+ }
65
+
59
66
  function pickString(value: unknown): string | null {
60
67
  return typeof value === 'string' && value.length > 0 ? value : null;
61
68
  }
@@ -142,6 +149,25 @@ export function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCa
142
149
  };
143
150
  }
144
151
 
152
+ export function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode {
153
+ const serialized = serializeCanvasNode(node);
154
+ if (serialized.type !== 'file' || typeof serialized.data.fileContent !== 'string') return serialized;
155
+ const fileContent = serialized.data.fileContent;
156
+ return {
157
+ ...serialized,
158
+ content: serialized.path,
159
+ data: {
160
+ ...serialized.data,
161
+ fileContent: {
162
+ omitted: 'file-content',
163
+ bytes: Buffer.byteLength(fileContent, 'utf-8'),
164
+ lineCount: fileContent.split('\n').length,
165
+ sha256: createHash('sha256').update(fileContent).digest('hex'),
166
+ } satisfies FileContentSummary,
167
+ },
168
+ };
169
+ }
170
+
145
171
  function summarizeBlobValue(value: unknown): unknown {
146
172
  if (!canvasState.isBlobReference(value)) return value;
147
173
  return {
@@ -5,15 +5,32 @@
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
+ } from './canvas-db.js';
17
34
  import {
18
35
  type CanvasPlacementRect,
19
36
  computeGroupBounds,
@@ -40,6 +57,7 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
40
57
 
41
58
  export const PMX_CANVAS_DIR = '.pmx-canvas';
42
59
  const STATE_FILENAME = 'state.json';
60
+ const DB_FILENAME = 'canvas.db';
43
61
  const SNAPSHOTS_SUBDIR = 'snapshots';
44
62
  const BLOBS_SUBDIR = 'blobs';
45
63
  const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
@@ -65,14 +83,8 @@ export interface PersistedBlobRef {
65
83
  jsonBytes: number;
66
84
  }
67
85
 
68
- interface PersistedCanvasState {
69
- version: number;
70
- viewport: ViewportState;
71
- nodes: CanvasNodeState[];
72
- edges: CanvasEdge[];
73
- annotations?: CanvasAnnotation[];
74
- contextPins: string[];
75
- }
86
+ // Re-export for backward compat — canonical definition is now in canvas-db.ts
87
+ export type { PersistedCanvasState } from './canvas-db.js';
76
88
 
77
89
  interface LoadFromDiskOptions {
78
90
  clearExisting?: boolean;
@@ -481,14 +493,38 @@ class CanvasStateManager {
481
493
 
482
494
  // ── Persistence ────────────────────────────────────────────
483
495
  private _stateFilePath: string | null = null;
496
+ private _db: import('bun:sqlite').Database | null = null;
484
497
  private _saveTimer: ReturnType<typeof setTimeout> | null = null;
485
498
 
486
499
  /** Set the workspace root to enable auto-persistence. */
487
500
  setWorkspaceRoot(workspaceRoot: string): void {
501
+ this.close();
488
502
  this._workspaceRoot = workspaceRoot;
489
503
  this.migrateLegacyLayout(workspaceRoot);
490
- const override = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
491
- this._stateFilePath = override || join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
504
+
505
+ // Determine DB path
506
+ const dbOverride = (process.env.PMX_CANVAS_DB_PATH ?? '').trim();
507
+ const stateFileOverride = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
508
+ let dbPath: string;
509
+ if (dbOverride) {
510
+ dbPath = dbOverride;
511
+ } else if (stateFileOverride && stateFileOverride.endsWith('.db')) {
512
+ dbPath = stateFileOverride;
513
+ } else {
514
+ dbPath = join(workspaceRoot, PMX_CANVAS_DIR, DB_FILENAME);
515
+ }
516
+
517
+ // Keep legacy _stateFilePath for JSON migration detection
518
+ this._stateFilePath = stateFileOverride && !stateFileOverride.endsWith('.db')
519
+ ? stateFileOverride
520
+ : join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
521
+
522
+ try {
523
+ this._db = openCanvasDb(dbPath);
524
+ this.migrateJsonToSqlite();
525
+ } catch (error) {
526
+ logCanvasStateWarning('open canvas database failed', error, { dbPath });
527
+ }
492
528
  }
493
529
 
494
530
  private get blobsDir(): string | null {
@@ -512,13 +548,33 @@ class CanvasStateManager {
512
548
  }
513
549
 
514
550
  private writeBlobValue(value: unknown): PersistedBlobRef | null {
515
- const dir = this.blobsDir;
516
- if (!dir) return null;
517
551
  const json = JSON.stringify(value);
518
552
  if (typeof json !== 'string') return null;
519
553
  const jsonBytes = Buffer.byteLength(json);
520
554
  if (jsonBytes < BLOB_JSON_THRESHOLD_BYTES) return null;
521
555
  const sha256 = createHash('sha256').update(json).digest('hex');
556
+
557
+ // Write to SQLite if DB is available
558
+ if (this._db) {
559
+ try {
560
+ const bytes = writeBlobToDB(this._db, sha256, json);
561
+ return {
562
+ __pmxCanvasBlob: 'v1',
563
+ path: `blobs/${sha256}`,
564
+ sha256,
565
+ encoding: 'json+gzip',
566
+ bytes,
567
+ jsonBytes,
568
+ };
569
+ } catch (error) {
570
+ logCanvasStateWarning('write blob to db failed', error, { sha256 });
571
+ return null;
572
+ }
573
+ }
574
+
575
+ // Fallback to filesystem (for when DB is not yet initialized)
576
+ const dir = this.blobsDir;
577
+ if (!dir) return null;
522
578
  const prefix = sha256.slice(0, 2);
523
579
  const filePath = join(dir, prefix, `${sha256}.json.gz`);
524
580
  try {
@@ -541,6 +597,24 @@ class CanvasStateManager {
541
597
  }
542
598
 
543
599
  private readBlobValue(ref: PersistedBlobRef): unknown {
600
+ // Try SQLite first
601
+ if (this._db) {
602
+ try {
603
+ const json = readBlobFromDB(this._db, ref.sha256);
604
+ if (json) {
605
+ const sha256 = createHash('sha256').update(json).digest('hex');
606
+ if (sha256 !== ref.sha256) {
607
+ logCanvasStateWarning('blob checksum mismatch (db)', 'checksum mismatch', { sha256: ref.sha256 });
608
+ return ref;
609
+ }
610
+ return JSON.parse(json) as unknown;
611
+ }
612
+ } catch (error) {
613
+ logCanvasStateWarning('read blob from db failed', error, { sha256: ref.sha256 });
614
+ }
615
+ }
616
+
617
+ // Fallback to filesystem (for legacy blobs not yet migrated)
544
618
  const filePath = this.resolveBlobPath(ref);
545
619
  if (!filePath) return ref;
546
620
  try {
@@ -629,6 +703,84 @@ class CanvasStateManager {
629
703
  }
630
704
  }
631
705
 
706
+ /**
707
+ * One-time migration: import state.json + snapshot JSON files + blob files
708
+ * into the SQLite database. Renames originals to `.bak`.
709
+ */
710
+ private migrateJsonToSqlite(): void {
711
+ if (!this._db || !this._stateFilePath) return;
712
+ const db = this._db;
713
+
714
+ if (isDbPopulated(this._db)) return; // DB already initialized
715
+
716
+ if (existsSync(this._stateFilePath)) {
717
+ try {
718
+ const raw = readFileSync(this._stateFilePath, 'utf-8');
719
+ const parsed = JSON.parse(raw) as PersistedCanvasState;
720
+ if (parsed && parsed.version === 1) {
721
+ saveStateToDB(db, parsed);
722
+ renameSync(this._stateFilePath, `${this._stateFilePath}.bak`);
723
+ }
724
+ } catch (error) {
725
+ logCanvasStateWarning('migrate state.json to sqlite failed', error, {
726
+ path: this._stateFilePath,
727
+ });
728
+ }
729
+ }
730
+
731
+ // Migrate snapshot JSON files
732
+ const snapshotsDir = this.snapshotsDir;
733
+ if (snapshotsDir && existsSync(snapshotsDir)) {
734
+ try {
735
+ const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json'));
736
+ for (const file of files) {
737
+ try {
738
+ const filePath = join(snapshotsDir, file);
739
+ const raw = readFileSync(filePath, 'utf-8');
740
+ const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
741
+ if (parsed.snapshot && parsed.version === 1) {
742
+ saveSnapshotToDB(db, parsed.snapshot, parsed);
743
+ renameSync(filePath, `${filePath}.bak`);
744
+ }
745
+ } catch (error) {
746
+ logCanvasStateWarning('migrate snapshot file to sqlite failed', error, { file });
747
+ }
748
+ }
749
+ } catch (error) {
750
+ logCanvasStateWarning('migrate snapshots dir failed', error, { snapshotsDir });
751
+ }
752
+ }
753
+
754
+ // Migrate blob files
755
+ const blobsDir = this.blobsDir;
756
+ if (blobsDir && existsSync(blobsDir)) {
757
+ try {
758
+ const prefixes = readdirSync(blobsDir).filter((d) => d.length === 2);
759
+ for (const prefix of prefixes) {
760
+ const prefixDir = join(blobsDir, prefix);
761
+ const blobFiles = readdirSync(prefixDir).filter((f) => f.endsWith('.json.gz'));
762
+ for (const blobFile of blobFiles) {
763
+ try {
764
+ const blobPath = join(prefixDir, blobFile);
765
+ const sha256 = blobFile.replace('.json.gz', '');
766
+ if (!hasBlobInDB(db, sha256)) {
767
+ const compressed = readFileSync(blobPath);
768
+ const json = gunzipSync(compressed).toString('utf-8');
769
+ writeBlobToDB(db, sha256, json);
770
+ }
771
+ const backupPath = `${blobPath}.bak`;
772
+ if (!existsSync(backupPath)) renameSync(blobPath, backupPath);
773
+ } catch (error) {
774
+ logCanvasStateWarning('migrate blob file to sqlite failed', error, { blobFile });
775
+ }
776
+ }
777
+ }
778
+ } catch (error) {
779
+ logCanvasStateWarning('migrate blobs dir failed', error, { blobsDir });
780
+ }
781
+ }
782
+ }
783
+
632
784
  getWorkspaceRoot(): string {
633
785
  return this._workspaceRoot;
634
786
  }
@@ -644,31 +796,45 @@ class CanvasStateManager {
644
796
  };
645
797
  }
646
798
 
647
- /** Load canvas state from disk. Call once on server startup. */
799
+ /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
648
800
  loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
649
- if (!this._stateFilePath || !existsSync(this._stateFilePath)) {
650
- if (options.clearExisting) {
651
- this.applyPersistedState(this.emptyPersistedState());
801
+ // Try SQLite first (only if DB has been populated)
802
+ if (this._db && isDbPopulated(this._db)) {
803
+ try {
804
+ const state = loadStateFromDB(this._db);
805
+ if (state) {
806
+ this.applyPersistedState(state);
807
+ return true;
808
+ }
809
+ } catch (error) {
810
+ logCanvasStateWarning('load state from sqlite failed', error, {});
652
811
  }
653
- return false;
654
812
  }
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;
813
+
814
+ // Fallback to JSON (for edge cases where migration hasn't happened)
815
+ if (this._stateFilePath && existsSync(this._stateFilePath)) {
816
+ try {
817
+ const raw = readFileSync(this._stateFilePath, 'utf-8');
818
+ const parsed = JSON.parse(raw) as PersistedCanvasState;
819
+ if (!parsed || parsed.version !== 1) return false;
820
+ this.applyPersistedState(parsed);
821
+ return true;
822
+ } catch (error) {
823
+ logCanvasStateWarning('load state from json fallback failed', error, {
824
+ path: this._stateFilePath,
825
+ });
826
+ }
827
+ }
828
+
829
+ if (options.clearExisting) {
830
+ this.applyPersistedState(this.emptyPersistedState());
666
831
  }
832
+ return false;
667
833
  }
668
834
 
669
- /** Debounced save — coalesces rapid mutations into a single disk write. */
835
+ /** Debounced save — coalesces rapid mutations into a single write. */
670
836
  private scheduleSave(): void {
671
- if (!this._stateFilePath) return;
837
+ if (!this._db) return;
672
838
  if (this._saveTimer) clearTimeout(this._saveTimer);
673
839
  this._saveTimer = setTimeout(() => {
674
840
  this._saveTimer = null;
@@ -682,15 +848,19 @@ class CanvasStateManager {
682
848
  this._saveTimer = null;
683
849
  }
684
850
  this.saveToDisk();
851
+ if (this._db) {
852
+ try {
853
+ checkpointCanvasDb(this._db);
854
+ } catch (error) {
855
+ logCanvasStateWarning('checkpoint database failed', error, {});
856
+ }
857
+ }
685
858
  }
686
859
 
687
- /** Write current state to disk immediately. */
860
+ /** Write current state to SQLite immediately. */
688
861
  private saveToDisk(): void {
689
- if (!this._stateFilePath) return;
862
+ if (!this._db) return;
690
863
  try {
691
- const dir = dirname(this._stateFilePath);
692
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
693
-
694
864
  const payload = this.externalizePersistedStateBlobs({
695
865
  version: 1,
696
866
  viewport: this._viewport,
@@ -699,11 +869,31 @@ class CanvasStateManager {
699
869
  annotations: Array.from(this.annotations.values()),
700
870
  contextPins: Array.from(this._contextPinnedNodeIds),
701
871
  });
702
- writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
872
+ saveStateToDB(this._db, payload);
703
873
  } catch (error) {
704
- logCanvasStateWarning('save state to disk failed', error, {
705
- path: this._stateFilePath ?? undefined,
706
- });
874
+ logCanvasStateWarning('save state to sqlite failed', error, {});
875
+ }
876
+ }
877
+
878
+ /** Close the SQLite database cleanly. Call on server shutdown. */
879
+ close(): void {
880
+ if (this._saveTimer) {
881
+ clearTimeout(this._saveTimer);
882
+ this._saveTimer = null;
883
+ this.saveToDisk();
884
+ }
885
+ if (this._db) {
886
+ try {
887
+ finalizeCanvasDbForClose(this._db);
888
+ } catch (error) {
889
+ logCanvasStateWarning('finalize database failed', error, {});
890
+ }
891
+ try {
892
+ this._db.close();
893
+ } catch (error) {
894
+ logCanvasStateWarning('close database failed', error, {});
895
+ }
896
+ this._db = null;
707
897
  }
708
898
  }
709
899
 
@@ -754,6 +944,13 @@ class CanvasStateManager {
754
944
  snapshot: CanvasSnapshot;
755
945
  state: PersistedCanvasState;
756
946
  } | null {
947
+ // Try SQLite first
948
+ if (this._db) {
949
+ const result = loadSnapshotFromDB(this._db, idOrName);
950
+ if (result) return result;
951
+ }
952
+
953
+ // Fallback to filesystem (for legacy snapshots not yet migrated)
757
954
  const dir = this.snapshotsDir;
758
955
  if (!dir || !existsSync(dir)) return null;
759
956
 
@@ -817,8 +1014,7 @@ class CanvasStateManager {
817
1014
 
818
1015
  /** Save current canvas state as a named snapshot. */
819
1016
  saveSnapshot(name: string): CanvasSnapshot | null {
820
- const dir = this.snapshotsDir;
821
- if (!dir) return null;
1017
+ if (!this._db) return null;
822
1018
 
823
1019
  const id = `snap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
824
1020
  const snapshot: CanvasSnapshot = {
@@ -830,18 +1026,15 @@ class CanvasStateManager {
830
1026
  };
831
1027
 
832
1028
  try {
833
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
834
-
835
1029
  const payload = this.externalizePersistedStateBlobs({
836
1030
  version: 1,
837
- snapshot,
838
1031
  viewport: this._viewport,
839
1032
  nodes: Array.from(this.nodes.values()),
840
1033
  edges: Array.from(this.edges.values()),
841
1034
  annotations: Array.from(this.annotations.values()),
842
1035
  contextPins: Array.from(this._contextPinnedNodeIds),
843
1036
  });
844
- writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
1037
+ saveSnapshotToDB(this._db, snapshot, payload);
845
1038
  snapshot.nodeCount = payload.nodes.length;
846
1039
  snapshot.edgeCount = payload.edges.length;
847
1040
  return snapshot;
@@ -853,6 +1046,15 @@ class CanvasStateManager {
853
1046
 
854
1047
  /** List saved snapshots, newest first. */
855
1048
  listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
1049
+ if (this._db) {
1050
+ try {
1051
+ return listSnapshotsFromDB(this._db, options);
1052
+ } catch (error) {
1053
+ logCanvasStateWarning('list snapshots from db failed', error, {});
1054
+ }
1055
+ }
1056
+
1057
+ // Fallback to filesystem
856
1058
  const dir = this.snapshotsDir;
857
1059
  if (!dir || !existsSync(dir)) return [];
858
1060
 
@@ -982,6 +1184,16 @@ class CanvasStateManager {
982
1184
 
983
1185
  /** Delete a snapshot. */
984
1186
  deleteSnapshot(id: string): boolean {
1187
+ // Try SQLite first
1188
+ if (this._db) {
1189
+ try {
1190
+ if (deleteSnapshotFromDB(this._db, id)) return true;
1191
+ } catch (error) {
1192
+ logCanvasStateWarning('delete snapshot from db failed', error, { id });
1193
+ }
1194
+ }
1195
+
1196
+ // Fallback to filesystem
985
1197
  const dir = this.snapshotsDir;
986
1198
  if (!dir) return false;
987
1199
  const filePath = join(dir, `${id}.json`);
@@ -995,6 +1207,23 @@ class CanvasStateManager {
995
1207
  }
996
1208
  }
997
1209
 
1210
+ /** Remove all snapshots from the DB. Used by test teardown. */
1211
+ clearAllSnapshots(): void {
1212
+ if (this._db) {
1213
+ this._db.run('DELETE FROM snapshots');
1214
+ this._db.run('DELETE FROM snapshot_nodes');
1215
+ this._db.run('DELETE FROM snapshot_edges');
1216
+ this._db.run('DELETE FROM snapshot_annotations');
1217
+ this._db.run('DELETE FROM snapshot_pins');
1218
+ this._db.run('DELETE FROM snapshot_meta');
1219
+ }
1220
+ // Also clear filesystem snapshots dir
1221
+ const dir = this.snapshotsDir;
1222
+ if (dir && existsSync(dir)) {
1223
+ rmSync(dir, { recursive: true, force: true });
1224
+ }
1225
+ }
1226
+
998
1227
  // ── Node CRUD ──────────────────────────────────────────────
999
1228
 
1000
1229
  get viewport(): ViewportState {
@@ -40,6 +40,10 @@ function overlaps(a: CanvasNodeState, b: CanvasNodeState): boolean {
40
40
  );
41
41
  }
42
42
 
43
+ function participatesInCanvasCollisionValidation(node: CanvasNodeState): boolean {
44
+ return node.dockPosition === null;
45
+ }
46
+
43
47
  function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean {
44
48
  return (
45
49
  child.position.x >= group.position.x &&
@@ -81,8 +85,10 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
81
85
 
82
86
  for (let i = 0; i < layout.nodes.length; i++) {
83
87
  const a = layout.nodes[i]!;
88
+ if (!participatesInCanvasCollisionValidation(a)) continue;
84
89
  for (let j = i + 1; j < layout.nodes.length; j++) {
85
90
  const b = layout.nodes[j]!;
91
+ if (!participatesInCanvasCollisionValidation(b)) continue;
86
92
  if (!overlaps(a, b)) continue;
87
93
 
88
94
  if (isGroupChildPair(a, b)) {
@@ -511,14 +511,23 @@ const PRESENTATION_THEMES: Record<PresentationThemeName, PresentationThemeTokens
511
511
  },
512
512
  };
513
513
 
514
+ function isPresentationThemeName(value: string): value is PresentationThemeName {
515
+ return value === 'canvas' || value === 'midnight' || value === 'paper' || value === 'aurora';
516
+ }
517
+
518
+ function parsePresentationThemeName(value: string, field = 'theme'): PresentationThemeName {
519
+ if (isPresentationThemeName(value)) return value;
520
+ throw new Error(`Invalid presentation ${field} "${value}". Use canvas, midnight, paper, aurora, or a custom theme object.`);
521
+ }
522
+
514
523
  function presentationTheme(data: Record<string, unknown>): PresentationThemeTokens {
515
524
  const raw = data.theme ?? data.presentationTheme;
516
525
  if (typeof raw === 'string') {
517
- return PRESENTATION_THEMES[raw as PresentationThemeName] ?? PRESENTATION_THEMES.canvas;
526
+ return PRESENTATION_THEMES[parsePresentationThemeName(raw)];
518
527
  }
519
528
  if (!isRecord(raw)) return PRESENTATION_THEMES.canvas;
520
- const baseName = typeof raw.base === 'string' && raw.base in PRESENTATION_THEMES
521
- ? raw.base as PresentationThemeName
529
+ const baseName = typeof raw.base === 'string'
530
+ ? parsePresentationThemeName(raw.base, 'theme base')
522
531
  : 'canvas';
523
532
  const base = PRESENTATION_THEMES[baseName];
524
533
  const readColor = (key: string, fallback: string): string => {
@@ -544,8 +553,9 @@ function presentationTheme(data: Record<string, unknown>): PresentationThemeToke
544
553
 
545
554
  function presentationThemeMetadata(data: Record<string, unknown>): string | Record<string, string> | undefined {
546
555
  const raw = data.theme ?? data.presentationTheme;
547
- if (typeof raw === 'string') return raw;
556
+ if (typeof raw === 'string') return parsePresentationThemeName(raw);
548
557
  if (!isRecord(raw)) return undefined;
558
+ if (typeof raw.base === 'string') parsePresentationThemeName(raw.base, 'theme base');
549
559
  const result: Record<string, string> = {};
550
560
  for (const [key, value] of Object.entries(raw)) {
551
561
  if (typeof value === 'string') result[key] = value;
@@ -1436,7 +1436,8 @@ async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<R
1436
1436
 
1437
1437
  async function handleCanvasAddNode(req: Request): Promise<Response> {
1438
1438
  const body = await readJson(req);
1439
- const type = (body.type as string) || 'markdown';
1439
+ const queryType = new URL(req.url).searchParams.get('type');
1440
+ const type = typeof body.type === 'string' ? body.type : queryType || 'markdown';
1440
1441
 
1441
1442
  if (!VALID_NODE_TYPES.has(type)) {
1442
1443
  if (type === 'json-render') {
@@ -1541,11 +1542,16 @@ function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response
1541
1542
  return responseJson({ ok: false, error: `Unknown HTML primitive: ${String(rawKind)}.` }, 400);
1542
1543
  }
1543
1544
  const data = isRecord(body.data) ? body.data : {};
1544
- const built = buildHtmlPrimitive({
1545
- kind: rawKind,
1546
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1547
- data,
1548
- });
1545
+ let built: ReturnType<typeof buildHtmlPrimitive>;
1546
+ try {
1547
+ built = buildHtmlPrimitive({
1548
+ kind: rawKind,
1549
+ ...(typeof body.title === 'string' ? { title: body.title } : {}),
1550
+ data,
1551
+ });
1552
+ } catch (error) {
1553
+ return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1554
+ }
1549
1555
  const geometry = resolveCreateGeometry(body);
1550
1556
  const { node } = addCanvasNode({
1551
1557
  type: 'html',
@@ -4074,7 +4080,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4074
4080
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
4075
4081
  });
4076
4082
  if (loaded) {
4077
- console.log(' Canvas state restored from .pmx-canvas/state.json');
4083
+ console.log(' Canvas state restored from .pmx-canvas/canvas.db');
4078
4084
  primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
4079
4085
  void syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true }).finally(() => {
4080
4086
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -4477,7 +4483,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4477
4483
  }
4478
4484
 
4479
4485
  export function stopCanvasServer(): void {
4480
- canvasState.flushToDisk();
4486
+ canvasState.close();
4481
4487
  closeAllMcpAppSessions();
4482
4488
  setCanvasLayoutUpdateEmitter(null);
4483
4489
  void closeCanvasAutomationWebViewInternal().catch((error) => {