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.
- package/CHANGELOG.md +135 -0
- package/Readme.md +4 -3
- package/dist/canvas/global.css +23 -40
- package/dist/canvas/index.js +44 -44
- package/dist/types/server/canvas-db.d.ts +33 -0
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +18 -14
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +8 -4
- package/src/cli/agent.ts +25 -1
- package/src/cli/index.ts +3 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +25 -15
- package/src/client/nodes/HtmlNode.tsx +1 -1
- package/src/client/theme/global.css +23 -40
- package/src/server/canvas-db.ts +710 -0
- package/src/server/canvas-operations.ts +9 -4
- package/src/server/canvas-schema.ts +4 -4
- package/src/server/canvas-serialization.ts +26 -0
- package/src/server/canvas-state.ts +277 -48
- package/src/server/canvas-validation.ts +6 -0
- package/src/server/html-primitives.ts +14 -4
- package/src/server/server.ts +14 -8
|
@@ -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,
|
|
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
|
|
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
|
-
...(
|
|
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/
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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
|
|
799
|
+
/** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
|
|
648
800
|
loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
})
|
|
665
|
-
|
|
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
|
|
835
|
+
/** Debounced save — coalesces rapid mutations into a single write. */
|
|
670
836
|
private scheduleSave(): void {
|
|
671
|
-
if (!this.
|
|
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
|
|
860
|
+
/** Write current state to SQLite immediately. */
|
|
688
861
|
private saveToDisk(): void {
|
|
689
|
-
if (!this.
|
|
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
|
-
|
|
872
|
+
saveStateToDB(this._db, payload);
|
|
703
873
|
} catch (error) {
|
|
704
|
-
logCanvasStateWarning('save state to
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
526
|
+
return PRESENTATION_THEMES[parsePresentationThemeName(raw)];
|
|
518
527
|
}
|
|
519
528
|
if (!isRecord(raw)) return PRESENTATION_THEMES.canvas;
|
|
520
|
-
const baseName = typeof raw.base === 'string'
|
|
521
|
-
? raw.base
|
|
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;
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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/
|
|
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.
|
|
4486
|
+
canvasState.close();
|
|
4481
4487
|
closeAllMcpAppSessions();
|
|
4482
4488
|
setCanvasLayoutUpdateEmitter(null);
|
|
4483
4489
|
void closeCanvasAutomationWebViewInternal().catch((error) => {
|