pmx-canvas 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/Readme.md +11 -3
- package/dist/canvas/index.js +67 -67
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/operations/composites.d.ts +6 -21
- package/dist/types/server/server.d.ts +1 -0
- package/docs/cli.md +5 -0
- package/docs/environment.md +2 -2
- package/docs/http-api.md +25 -1
- package/docs/mcp.md +21 -30
- package/docs/plans/plan-009-tech-debt-backlog.md +13 -12
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +7 -7
- package/skills/pmx-canvas/SKILL.md +16 -6
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/full-reference.md +17 -15
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +31 -1
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/index.tsx +2 -0
- package/src/client/nodes/ExtAppFrame.tsx +17 -2
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +209 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +24 -21
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +170 -74
|
@@ -7,20 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
|
|
9
9
|
* in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
|
|
10
|
-
* Legacy `.pmx-canvas/state.json
|
|
10
|
+
* Legacy pre-0.2 formats (`.pmx-canvas/state.json`, `.pmx-canvas.json`,
|
|
11
|
+
* `.pmx-canvas-snapshots/`, loose blob files) are no longer imported as of 0.4.0 —
|
|
12
|
+
* the one-shot boot migration into SQLite was retired.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import { createHash } from 'node:crypto';
|
|
14
|
-
import {
|
|
15
|
-
existsSync,
|
|
16
|
-
readFileSync,
|
|
17
|
-
writeFileSync,
|
|
18
|
-
mkdirSync,
|
|
19
|
-
readdirSync,
|
|
20
|
-
renameSync,
|
|
21
|
-
rmSync,
|
|
22
|
-
unlinkSync,
|
|
23
|
-
} from 'node:fs';
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
|
|
24
17
|
import { isAbsolute, join, dirname, relative } from 'node:path';
|
|
25
18
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
26
19
|
import { normalizeCanvasNodeData } from './canvas-provenance.js';
|
|
@@ -34,7 +27,6 @@ import {
|
|
|
34
27
|
deleteSnapshotFromDB,
|
|
35
28
|
writeBlobToDB,
|
|
36
29
|
readBlobFromDB,
|
|
37
|
-
hasBlobInDB,
|
|
38
30
|
isDbPopulated,
|
|
39
31
|
checkpointCanvasDb,
|
|
40
32
|
finalizeCanvasDbForClose,
|
|
@@ -97,12 +89,9 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
|
|
|
97
89
|
}
|
|
98
90
|
|
|
99
91
|
export const PMX_CANVAS_DIR = '.pmx-canvas';
|
|
100
|
-
const STATE_FILENAME = 'state.json';
|
|
101
92
|
const DB_FILENAME = 'canvas.db';
|
|
102
93
|
const SNAPSHOTS_SUBDIR = 'snapshots';
|
|
103
94
|
const BLOBS_SUBDIR = 'blobs';
|
|
104
|
-
const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
|
|
105
|
-
const LEGACY_SNAPSHOTS_DIR = '.pmx-canvas-snapshots';
|
|
106
95
|
const SAVE_DEBOUNCE_MS = 500;
|
|
107
96
|
const BLOB_JSON_THRESHOLD_BYTES = Number(process.env.PMX_CANVAS_BLOB_THRESHOLD_BYTES ?? '2048');
|
|
108
97
|
const BLOB_DATA_FIELDS = new Set([
|
|
@@ -393,10 +382,23 @@ class CanvasStateManager {
|
|
|
393
382
|
}
|
|
394
383
|
|
|
395
384
|
// ── Mutation recorder (for undo/redo history) ─────────────
|
|
385
|
+
//
|
|
386
|
+
// Single slot BY DESIGN, last-write-wins (plan-009 L5): the only logical
|
|
387
|
+
// consumer is the `mutationHistory` singleton, wired by `startCanvasServer()`
|
|
388
|
+
// (server.ts) on every fresh start. A listener LIST here would record every
|
|
389
|
+
// mutation twice into the history ring (breaking undo) if a second wire ever
|
|
390
|
+
// appeared, so keep it a single slot and wire recorders in one place only.
|
|
396
391
|
private _mutationRecorder: ((info: MutationRecordInfo) => void) | null = null;
|
|
392
|
+
// The ONE recording-suppression counter (depth-counted so nested suppressed
|
|
393
|
+
// scopes compose). Two related mechanisms are deliberately NOT merged into
|
|
394
|
+
// it: `mutationHistory._replaying` guards direct `record()` calls during
|
|
395
|
+
// undo/redo replay, and the operation registry's `suppressEmitDepth`
|
|
396
|
+
// suppresses SSE emits during meta-ops (canvas.batch) — whose sub-ops must
|
|
397
|
+
// still record history, just as undo/redo still emits SSE while recording is
|
|
398
|
+
// suppressed here. The three counters are active at different times.
|
|
397
399
|
private _suppressRecordingDepth = 0;
|
|
398
400
|
|
|
399
|
-
/** Register
|
|
401
|
+
/** Register THE mutation recorder (single slot, last-write-wins — see note above). */
|
|
400
402
|
onMutation(cb: (info: MutationRecordInfo) => void): void {
|
|
401
403
|
this._mutationRecorder = cb;
|
|
402
404
|
}
|
|
@@ -617,7 +619,6 @@ class CanvasStateManager {
|
|
|
617
619
|
}
|
|
618
620
|
|
|
619
621
|
// ── Persistence ────────────────────────────────────────────
|
|
620
|
-
private _stateFilePath: string | null = null;
|
|
621
622
|
private _db: import('bun:sqlite').Database | null = null;
|
|
622
623
|
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
623
624
|
|
|
@@ -630,9 +631,9 @@ class CanvasStateManager {
|
|
|
630
631
|
setWorkspaceRoot(workspaceRoot: string): void {
|
|
631
632
|
this.close();
|
|
632
633
|
this._workspaceRoot = workspaceRoot;
|
|
633
|
-
this.migrateLegacyLayout(workspaceRoot);
|
|
634
634
|
|
|
635
|
-
// Determine DB path
|
|
635
|
+
// Determine DB path. PMX_CANVAS_DB_PATH wins; PMX_CANVAS_STATE_FILE is
|
|
636
|
+
// honored only as a legacy alias when it points at a `.db` file.
|
|
636
637
|
const dbOverride = (process.env.PMX_CANVAS_DB_PATH ?? '').trim();
|
|
637
638
|
const stateFileOverride = (process.env.PMX_CANVAS_STATE_FILE ?? '').trim();
|
|
638
639
|
let dbPath: string;
|
|
@@ -643,16 +644,20 @@ class CanvasStateManager {
|
|
|
643
644
|
} else {
|
|
644
645
|
dbPath = join(workspaceRoot, PMX_CANVAS_DIR, DB_FILENAME);
|
|
645
646
|
}
|
|
647
|
+
if (stateFileOverride && !stateFileOverride.endsWith('.db')) {
|
|
648
|
+
logCanvasStateWarning(
|
|
649
|
+
'PMX_CANVAS_STATE_FILE ignored',
|
|
650
|
+
'legacy JSON state files are no longer supported as of 0.4.0 — use PMX_CANVAS_DB_PATH',
|
|
651
|
+
{ stateFileOverride },
|
|
652
|
+
);
|
|
653
|
+
}
|
|
646
654
|
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
: join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
|
|
652
|
-
|
|
655
|
+
// Legacy pre-0.2 formats (`.pmx-canvas/state.json`, `.pmx-canvas.json`,
|
|
656
|
+
// `.pmx-canvas-snapshots/`, loose blob files) are no longer imported as of
|
|
657
|
+
// 0.4.0 — the one-shot boot migration that copied them into SQLite and
|
|
658
|
+
// renamed the originals to `.bak` was retired.
|
|
653
659
|
try {
|
|
654
660
|
this._db = openCanvasDb(dbPath);
|
|
655
|
-
this.migrateJsonToSqlite();
|
|
656
661
|
} catch (error) {
|
|
657
662
|
logCanvasStateWarning('open canvas database failed', error, { dbPath });
|
|
658
663
|
}
|
|
@@ -745,7 +750,7 @@ class CanvasStateManager {
|
|
|
745
750
|
}
|
|
746
751
|
}
|
|
747
752
|
|
|
748
|
-
// Fallback to filesystem (
|
|
753
|
+
// Fallback to filesystem (legacy on-disk blobs are still readable in place)
|
|
749
754
|
const filePath = this.resolveBlobPath(ref);
|
|
750
755
|
if (!filePath) return ref;
|
|
751
756
|
try {
|
|
@@ -804,112 +809,6 @@ class CanvasStateManager {
|
|
|
804
809
|
};
|
|
805
810
|
}
|
|
806
811
|
|
|
807
|
-
/**
|
|
808
|
-
* One-time migration: rename files from the pre-consolidation layout
|
|
809
|
-
* (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
|
|
810
|
-
* No-op when the new layout already exists.
|
|
811
|
-
*/
|
|
812
|
-
private migrateLegacyLayout(workspaceRoot: string): void {
|
|
813
|
-
const newDir = join(workspaceRoot, PMX_CANVAS_DIR);
|
|
814
|
-
const legacyState = join(workspaceRoot, LEGACY_STATE_FILENAME);
|
|
815
|
-
const newState = join(newDir, STATE_FILENAME);
|
|
816
|
-
const legacySnapshots = join(workspaceRoot, LEGACY_SNAPSHOTS_DIR);
|
|
817
|
-
const newSnapshots = join(newDir, SNAPSHOTS_SUBDIR);
|
|
818
|
-
|
|
819
|
-
try {
|
|
820
|
-
if (existsSync(legacyState) && !existsSync(newState)) {
|
|
821
|
-
mkdirSync(newDir, { recursive: true });
|
|
822
|
-
renameSync(legacyState, newState);
|
|
823
|
-
}
|
|
824
|
-
if (existsSync(legacySnapshots) && !existsSync(newSnapshots)) {
|
|
825
|
-
mkdirSync(newDir, { recursive: true });
|
|
826
|
-
renameSync(legacySnapshots, newSnapshots);
|
|
827
|
-
}
|
|
828
|
-
} catch (error) {
|
|
829
|
-
logCanvasStateWarning('legacy layout migration failed', error, {
|
|
830
|
-
workspaceRoot,
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* One-time migration: import state.json + snapshot JSON files + blob files
|
|
837
|
-
* into the SQLite database. Renames originals to `.bak`.
|
|
838
|
-
*/
|
|
839
|
-
private migrateJsonToSqlite(): void {
|
|
840
|
-
if (!this._db || !this._stateFilePath) return;
|
|
841
|
-
const db = this._db;
|
|
842
|
-
|
|
843
|
-
if (isDbPopulated(this._db)) return; // DB already initialized
|
|
844
|
-
|
|
845
|
-
if (existsSync(this._stateFilePath)) {
|
|
846
|
-
try {
|
|
847
|
-
const raw = readFileSync(this._stateFilePath, 'utf-8');
|
|
848
|
-
const parsed = JSON.parse(raw) as PersistedCanvasState;
|
|
849
|
-
if (parsed && parsed.version === 1) {
|
|
850
|
-
saveStateToDB(db, parsed);
|
|
851
|
-
renameSync(this._stateFilePath, `${this._stateFilePath}.bak`);
|
|
852
|
-
}
|
|
853
|
-
} catch (error) {
|
|
854
|
-
logCanvasStateWarning('migrate state.json to sqlite failed', error, {
|
|
855
|
-
path: this._stateFilePath,
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Migrate snapshot JSON files
|
|
861
|
-
const snapshotsDir = this.snapshotsDir;
|
|
862
|
-
if (snapshotsDir && existsSync(snapshotsDir)) {
|
|
863
|
-
try {
|
|
864
|
-
const files = readdirSync(snapshotsDir).filter((f) => f.endsWith('.json'));
|
|
865
|
-
for (const file of files) {
|
|
866
|
-
try {
|
|
867
|
-
const filePath = join(snapshotsDir, file);
|
|
868
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
869
|
-
const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
|
|
870
|
-
if (parsed.snapshot && parsed.version === 1) {
|
|
871
|
-
saveSnapshotToDB(db, parsed.snapshot, parsed);
|
|
872
|
-
renameSync(filePath, `${filePath}.bak`);
|
|
873
|
-
}
|
|
874
|
-
} catch (error) {
|
|
875
|
-
logCanvasStateWarning('migrate snapshot file to sqlite failed', error, { file });
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
} catch (error) {
|
|
879
|
-
logCanvasStateWarning('migrate snapshots dir failed', error, { snapshotsDir });
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// Migrate blob files
|
|
884
|
-
const blobsDir = this.blobsDir;
|
|
885
|
-
if (blobsDir && existsSync(blobsDir)) {
|
|
886
|
-
try {
|
|
887
|
-
const prefixes = readdirSync(blobsDir).filter((d) => d.length === 2);
|
|
888
|
-
for (const prefix of prefixes) {
|
|
889
|
-
const prefixDir = join(blobsDir, prefix);
|
|
890
|
-
const blobFiles = readdirSync(prefixDir).filter((f) => f.endsWith('.json.gz'));
|
|
891
|
-
for (const blobFile of blobFiles) {
|
|
892
|
-
try {
|
|
893
|
-
const blobPath = join(prefixDir, blobFile);
|
|
894
|
-
const sha256 = blobFile.replace('.json.gz', '');
|
|
895
|
-
if (!hasBlobInDB(db, sha256)) {
|
|
896
|
-
const compressed = readFileSync(blobPath);
|
|
897
|
-
const json = gunzipSync(compressed).toString('utf-8');
|
|
898
|
-
writeBlobToDB(db, sha256, json);
|
|
899
|
-
}
|
|
900
|
-
const backupPath = `${blobPath}.bak`;
|
|
901
|
-
if (!existsSync(backupPath)) renameSync(blobPath, backupPath);
|
|
902
|
-
} catch (error) {
|
|
903
|
-
logCanvasStateWarning('migrate blob file to sqlite failed', error, { blobFile });
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
} catch (error) {
|
|
908
|
-
logCanvasStateWarning('migrate blobs dir failed', error, { blobsDir });
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
812
|
getWorkspaceRoot(): string {
|
|
914
813
|
return this._workspaceRoot;
|
|
915
814
|
}
|
|
@@ -927,11 +826,12 @@ class CanvasStateManager {
|
|
|
927
826
|
};
|
|
928
827
|
}
|
|
929
828
|
|
|
930
|
-
/** Load canvas state from SQLite
|
|
829
|
+
/** Load canvas state from SQLite. Call once on server startup. */
|
|
931
830
|
loadFromDisk(options: LoadFromDiskOptions = {}): boolean {
|
|
932
831
|
// Host capability lives in its own table (not snapshotted / not in PmxAxState).
|
|
933
832
|
this.ax.loadHostCapabilityFromDb();
|
|
934
|
-
//
|
|
833
|
+
// Only a populated DB counts as saved state (legacy `state.json` loading was
|
|
834
|
+
// retired with the 0.4.0 boot-migration removal).
|
|
935
835
|
if (this._db && isDbPopulated(this._db)) {
|
|
936
836
|
try {
|
|
937
837
|
const state = loadStateFromDB(this._db);
|
|
@@ -944,21 +844,6 @@ class CanvasStateManager {
|
|
|
944
844
|
}
|
|
945
845
|
}
|
|
946
846
|
|
|
947
|
-
// Fallback to JSON (for edge cases where migration hasn't happened)
|
|
948
|
-
if (this._stateFilePath && existsSync(this._stateFilePath)) {
|
|
949
|
-
try {
|
|
950
|
-
const raw = readFileSync(this._stateFilePath, 'utf-8');
|
|
951
|
-
const parsed = JSON.parse(raw) as PersistedCanvasState;
|
|
952
|
-
if (!parsed || parsed.version !== 1) return false;
|
|
953
|
-
this.applyPersistedState(parsed);
|
|
954
|
-
return true;
|
|
955
|
-
} catch (error) {
|
|
956
|
-
logCanvasStateWarning('load state from json fallback failed', error, {
|
|
957
|
-
path: this._stateFilePath,
|
|
958
|
-
});
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
847
|
if (options.clearExisting) {
|
|
963
848
|
this.applyPersistedState(this.emptyPersistedState());
|
|
964
849
|
}
|
|
@@ -1015,11 +900,26 @@ class CanvasStateManager {
|
|
|
1015
900
|
ax: this.getAxState(),
|
|
1016
901
|
});
|
|
1017
902
|
saveStateToDB(this._db, payload);
|
|
903
|
+
this._lastPersistenceError = null;
|
|
1018
904
|
} catch (error) {
|
|
905
|
+
// Persistence failures are otherwise warn-and-continue (the canvas keeps
|
|
906
|
+
// working from memory) — record the failure so /health can report a
|
|
907
|
+
// degraded persistence state instead of silently losing durability (M4).
|
|
908
|
+
this._lastPersistenceError = {
|
|
909
|
+
message: error instanceof Error ? error.message : String(error),
|
|
910
|
+
at: new Date().toISOString(),
|
|
911
|
+
};
|
|
1019
912
|
logCanvasStateWarning('save state to sqlite failed', error, {});
|
|
1020
913
|
}
|
|
1021
914
|
}
|
|
1022
915
|
|
|
916
|
+
private _lastPersistenceError: { message: string; at: string } | null = null;
|
|
917
|
+
|
|
918
|
+
/** Health view of state persistence: ok until a save fails, ok again after the next success. */
|
|
919
|
+
get persistenceHealth(): { ok: boolean; lastError: { message: string; at: string } | null } {
|
|
920
|
+
return { ok: this._lastPersistenceError === null, lastError: this._lastPersistenceError };
|
|
921
|
+
}
|
|
922
|
+
|
|
1023
923
|
/** Close the SQLite database cleanly. Call on server shutdown. */
|
|
1024
924
|
close(): void {
|
|
1025
925
|
if (this._saveTimer) {
|
|
@@ -1098,7 +998,7 @@ class CanvasStateManager {
|
|
|
1098
998
|
if (result) return result;
|
|
1099
999
|
}
|
|
1100
1000
|
|
|
1101
|
-
// Fallback to filesystem (
|
|
1001
|
+
// Fallback to filesystem (legacy on-disk snapshots are still readable in place)
|
|
1102
1002
|
const dir = this.snapshotsDir;
|
|
1103
1003
|
if (!dir || !existsSync(dir)) return null;
|
|
1104
1004
|
|
package/src/server/index.ts
CHANGED
|
@@ -176,15 +176,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
176
176
|
this._server = base;
|
|
177
177
|
this._port = getCanvasServerPort() ?? this._port;
|
|
178
178
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
description: info.description,
|
|
183
|
-
operationType: info.operationType,
|
|
184
|
-
forward: info.forward,
|
|
185
|
-
inverse: info.inverse,
|
|
186
|
-
});
|
|
187
|
-
});
|
|
179
|
+
// The mutation-history recorder is wired by startCanvasServer above —
|
|
180
|
+
// onMutation is a single slot, so wiring it again here would only
|
|
181
|
+
// re-install an identical forwarder (plan-009 L5).
|
|
188
182
|
|
|
189
183
|
// Wire up prompt handler to emit events
|
|
190
184
|
setPrimaryWorkbenchCanvasPromptHandler(async (request) => {
|
|
@@ -16,27 +16,12 @@
|
|
|
16
16
|
* operation (plan-005 slices 1–7 + plan-008 Wave 1) that is now reachable ONLY
|
|
17
17
|
* through its composite (or `canvas_batch`).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Wave 3 via runner injection; `canvas_app` shipped in Wave 4 — open-mcp-app /
|
|
26
|
-
* diagram / build-artifact. Wave 5 folded the last three legacy tools
|
|
27
|
-
* deprecate-only — NO per-action input-injection mechanism was needed:
|
|
28
|
-
* `canvas_add_html_node` / `canvas_add_html_primitive` → `canvas_node` action
|
|
29
|
-
* "add" (type:"html" [+ primitive]); `canvas_refresh_webpage_node` → `canvas_node`
|
|
30
|
-
* action "update" (refresh:true). `canvas_screenshot` stays standalone — it
|
|
31
|
-
* returns a binary image payload the composite/registry JSON wire shape does not
|
|
32
|
-
* model.)
|
|
33
|
-
*
|
|
34
|
-
* Not shipped here: the `canvas_snapshot` composite (plan-006 #7). Its target
|
|
35
|
-
* name is ALREADY a legacy standalone tool (the save-snapshot tool, op
|
|
36
|
-
* `snapshot.save`), so it cannot be added additively without a name clash, and
|
|
37
|
-
* repurposing `canvas_snapshot` to be action-discriminated now would break
|
|
38
|
-
* existing callers. It lands in v0.4, in the same change that removes the 6
|
|
39
|
-
* kept-but-deprecated legacy snapshot tools and frees the name.
|
|
19
|
+
* v0.4.0 completed the fold: the `canvas_snapshot` composite shipped (its name
|
|
20
|
+
* was held by the legacy save-snapshot standalone through v0.3.x, which — with
|
|
21
|
+
* the other 5 snapshot standalones — was deprecated in 0.3.0 and removed here
|
|
22
|
+
* per docs/api-stability.md's deprecate-one-minor-before-removal rule).
|
|
23
|
+
* `canvas_screenshot` stays standalone — it returns a binary image payload the
|
|
24
|
+
* composite/registry JSON wire shape does not model.
|
|
40
25
|
*
|
|
41
26
|
* This module must never import server.ts or index.ts.
|
|
42
27
|
*/
|
|
@@ -138,6 +123,24 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
|
|
|
138
123
|
);
|
|
139
124
|
|
|
140
125
|
export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
126
|
+
{
|
|
127
|
+
// v0.4.0: the long-deferred snapshot composite (plan-006 #7). The name was
|
|
128
|
+
// held by the legacy save-snapshot standalone through v0.3.x; listing the
|
|
129
|
+
// composite here suppresses all 6 legacy snapshot standalones via
|
|
130
|
+
// compositeFoldedOpNames and frees the name in the same change.
|
|
131
|
+
toolName: 'canvas_snapshot',
|
|
132
|
+
description:
|
|
133
|
+
'Canvas snapshots: save the current state, list saved snapshots, restore one, delete one, garbage-collect old ones, or diff the live canvas against one. Action "save" stores a named snapshot; "list" returns saved snapshots (newest first); "restore" replaces the live canvas with a snapshot; "delete" removes one; "gc" keeps the newest N and deletes the rest (dryRun to preview); "diff" shows added/removed/modified nodes and edges versus a snapshot (by name or id).',
|
|
134
|
+
actionSummary: 'save | list | restore | delete | gc | diff',
|
|
135
|
+
actions: {
|
|
136
|
+
save: 'snapshot.save',
|
|
137
|
+
list: 'snapshot.list',
|
|
138
|
+
restore: 'snapshot.restore',
|
|
139
|
+
delete: 'snapshot.delete',
|
|
140
|
+
gc: 'snapshot.gc',
|
|
141
|
+
diff: 'snapshot.diff',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
141
144
|
{
|
|
142
145
|
toolName: 'canvas_node',
|
|
143
146
|
description:
|
|
@@ -70,7 +70,7 @@ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>
|
|
|
70
70
|
mcp: {
|
|
71
71
|
toolName: 'canvas_list_snapshots',
|
|
72
72
|
description:
|
|
73
|
-
'
|
|
73
|
+
'List saved canvas snapshots with IDs, names, timestamps, and node/edge counts. Defaults to the 20 newest snapshots; pass all=true to return every snapshot.',
|
|
74
74
|
extraShape: {
|
|
75
75
|
limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
|
|
76
76
|
query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
|
|
@@ -117,7 +117,7 @@ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>
|
|
|
117
117
|
mcp: {
|
|
118
118
|
toolName: 'canvas_snapshot',
|
|
119
119
|
description:
|
|
120
|
-
'
|
|
120
|
+
'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
|
|
121
121
|
extraShape: {
|
|
122
122
|
name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
|
|
123
123
|
},
|
|
@@ -155,7 +155,7 @@ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Re
|
|
|
155
155
|
mcp: {
|
|
156
156
|
toolName: 'canvas_gc_snapshots',
|
|
157
157
|
description:
|
|
158
|
-
'
|
|
158
|
+
'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
|
|
159
159
|
extraShape: {
|
|
160
160
|
keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
|
|
161
161
|
dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
|
|
@@ -206,7 +206,7 @@ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>
|
|
|
206
206
|
mcp: {
|
|
207
207
|
toolName: 'canvas_diff',
|
|
208
208
|
description:
|
|
209
|
-
'
|
|
209
|
+
'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
|
|
210
210
|
extraShape: {
|
|
211
211
|
snapshot: z.string().describe('Snapshot name or ID to compare against'),
|
|
212
212
|
},
|
|
@@ -291,7 +291,7 @@ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreS
|
|
|
291
291
|
mcp: {
|
|
292
292
|
toolName: 'canvas_restore',
|
|
293
293
|
description:
|
|
294
|
-
'
|
|
294
|
+
'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
|
|
295
295
|
extraShape: {
|
|
296
296
|
id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
|
|
297
297
|
},
|
|
@@ -346,8 +346,7 @@ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSch
|
|
|
346
346
|
},
|
|
347
347
|
mcp: {
|
|
348
348
|
toolName: 'canvas_delete_snapshot',
|
|
349
|
-
description:
|
|
350
|
-
'Deprecated: folds into the canvas_snapshot composite in v0.4 (actions save|list|restore|delete|gc|diff). Delete a saved snapshot by ID.',
|
|
349
|
+
description: 'Delete a saved snapshot by ID.',
|
|
351
350
|
extraShape: {
|
|
352
351
|
id: z.string().describe('Snapshot ID to delete'),
|
|
353
352
|
},
|
|
@@ -39,7 +39,10 @@ export function setOperationEventEmitter(emitter: OperationEventEmitter | null):
|
|
|
39
39
|
operationEventEmitter = emitter;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
// Depth-counted
|
|
42
|
+
// Depth-counted EMIT suppression (same pattern as, but deliberately distinct
|
|
43
|
+
// state from, canvasState's recording suppression: batch suppresses SSE emits
|
|
44
|
+
// while sub-ops still record history; undo/redo suppresses recording while
|
|
45
|
+
// still emitting SSE — merging the two counters would break one or the other).
|
|
43
46
|
// While > 0, emitOperationEvent is a no-op so a meta-op (canvas.batch) can run
|
|
44
47
|
// many sub-ops without producing per-entry SSE frames, then emit ONE final
|
|
45
48
|
// layout frame itself. Both the `mutates` auto-emit and `ctx.emit` route through
|