pmx-canvas 0.3.3 → 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 +99 -0
- package/Readme.md +13 -4
- package/dist/canvas/index.js +71 -71
- package/dist/json-render/index.js +47 -47
- package/dist/types/cli/agent.d.ts +13 -8
- package/dist/types/cli/commands/apps.d.ts +1 -0
- package/dist/types/cli/commands/ax.d.ts +1 -0
- package/dist/types/cli/commands/copilot.d.ts +1 -0
- package/dist/types/cli/commands/edges.d.ts +1 -0
- package/dist/types/cli/commands/groups.d.ts +1 -0
- package/dist/types/cli/commands/history.d.ts +1 -0
- package/dist/types/cli/commands/nodes.d.ts +1 -0
- package/dist/types/cli/commands/pins.d.ts +1 -0
- package/dist/types/cli/commands/query.d.ts +1 -0
- package/dist/types/cli/commands/view.d.ts +1 -0
- package/dist/types/cli/commands/webview.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +73 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -1
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -1
- package/dist/types/client/nodes/use-ax-surface-bridge.d.ts +20 -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-operations.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/diagram-presets.d.ts +8 -0
- package/dist/types/server/operations/composites.d.ts +6 -30
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +18 -0
- package/docs/bun-webview-integration.md +5 -0
- package/docs/cli.md +5 -0
- package/docs/environment.md +48 -0
- package/docs/http-api.md +25 -1
- package/docs/mcp.md +21 -30
- package/docs/node-types.md +5 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +34 -15
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +13 -10
- package/skills/pmx-canvas/SKILL.md +16 -6
- package/skills/pmx-canvas/evals/evals.json +7 -7
- package/skills/pmx-canvas/references/full-reference.md +25 -16
- package/src/cli/agent.ts +22 -3512
- package/src/cli/commands/apps.ts +165 -0
- package/src/cli/commands/ax.ts +604 -0
- package/src/cli/commands/copilot.ts +43 -0
- package/src/cli/commands/edges.ts +73 -0
- package/src/cli/commands/groups.ts +81 -0
- package/src/cli/commands/history.ts +220 -0
- package/src/cli/commands/nodes.ts +984 -0
- package/src/cli/commands/pins.ts +42 -0
- package/src/cli/commands/query.ts +223 -0
- package/src/cli/commands/view.ts +186 -0
- package/src/cli/commands/webview.ts +227 -0
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +861 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -2
- package/src/client/index.tsx +2 -0
- package/src/client/nodes/ExtAppFrame.tsx +70 -51
- package/src/client/nodes/HtmlNode.tsx +8 -51
- package/src/client/nodes/McpAppNode.tsx +12 -47
- package/src/client/nodes/use-ax-surface-bridge.ts +71 -0
- 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/json-render/renderer/index.tsx +3 -2
- package/src/mcp/server.ts +0 -47
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-operations.ts +8 -0
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/diagram-presets.ts +15 -1
- package/src/server/html-surface.ts +11 -6
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +25 -34
- package/src/server/operations/invoker.ts +10 -3
- package/src/server/operations/mcp.ts +51 -1
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +172 -74
- package/src/shared/ax-surface-protocol.ts +22 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +0 -1
- package/src/client/canvas/ContextPinHud.tsx +0 -17
|
@@ -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
|
|
|
@@ -41,6 +41,20 @@ export const EXCALIDRAW_MCP_TRANSPORT: ExternalMcpTransportConfig = {
|
|
|
41
41
|
url: EXCALIDRAW_MCP_URL,
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The diagram preset targets the hosted Excalidraw MCP server. Tests set
|
|
46
|
+
* PMX_CANVAS_EXCALIDRAW_MCP_COMMAND (a stdio command line, e.g.
|
|
47
|
+
* "bun run tests/fixtures/mcp-app-fixture.ts") so the unit suite never does a
|
|
48
|
+
* real network round-trip — the hosted dependency made diagram tests time out
|
|
49
|
+
* under parallel load (plan-009 M8).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveExcalidrawTransport(): ExternalMcpTransportConfig {
|
|
52
|
+
const override = process.env.PMX_CANVAS_EXCALIDRAW_MCP_COMMAND?.trim();
|
|
53
|
+
if (!override) return EXCALIDRAW_MCP_TRANSPORT;
|
|
54
|
+
const [command = override, ...args] = override.split(/\s+/);
|
|
55
|
+
return { type: 'stdio', command, args };
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
export interface DiagramPresetOpenInput {
|
|
45
59
|
elements: unknown;
|
|
46
60
|
nodeId?: string;
|
|
@@ -379,7 +393,7 @@ export function ensureExcalidrawCheckpointId(
|
|
|
379
393
|
export function buildExcalidrawOpenMcpAppInput(input: DiagramPresetOpenInput): ExcalidrawOpenMcpAppInput {
|
|
380
394
|
const elements = normalizeExcalidrawElementsForToolInput(input.elements);
|
|
381
395
|
const out: ExcalidrawOpenMcpAppInput = {
|
|
382
|
-
transport:
|
|
396
|
+
transport: resolveExcalidrawTransport(),
|
|
383
397
|
toolName: EXCALIDRAW_CREATE_VIEW_TOOL,
|
|
384
398
|
serverName: EXCALIDRAW_SERVER_NAME,
|
|
385
399
|
toolArguments: { elements },
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* postMessage required.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import {
|
|
19
|
+
AX_SURFACE_ACK_SOURCE,
|
|
20
|
+
AX_SURFACE_EMIT_SOURCE,
|
|
21
|
+
HTML_SURFACE_PUSH_SOURCE,
|
|
22
|
+
} from '../shared/ax-surface-protocol.js';
|
|
18
23
|
import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
|
|
19
24
|
|
|
20
25
|
export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
|
|
@@ -54,7 +59,7 @@ function buildThemeBridge(themeToken: string): string {
|
|
|
54
59
|
const PMX_CANVAS_THEME_TOKEN = ${token};
|
|
55
60
|
window.addEventListener('message', (event) => {
|
|
56
61
|
const message = event.data;
|
|
57
|
-
if (!message || message.source !== '
|
|
62
|
+
if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
|
|
58
63
|
if (typeof message.theme !== 'string') return;
|
|
59
64
|
document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
|
|
60
65
|
document.documentElement.setAttribute('data-theme', message.theme);
|
|
@@ -74,12 +79,12 @@ function buildPresentationEscapeBridge(exitToken: string): string {
|
|
|
74
79
|
const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
|
|
75
80
|
document.addEventListener('keydown', (event) => {
|
|
76
81
|
if (event.key === 'Escape') {
|
|
77
|
-
window.parent.postMessage({ source: '
|
|
82
|
+
window.parent.postMessage({ source: '${HTML_SURFACE_PUSH_SOURCE}', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
|
|
78
83
|
}
|
|
79
84
|
}, true);
|
|
80
85
|
window.addEventListener('message', (event) => {
|
|
81
86
|
const message = event.data;
|
|
82
|
-
if (!message || message.source !== '
|
|
87
|
+
if (!message || message.source !== '${HTML_SURFACE_PUSH_SOURCE}' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
|
|
83
88
|
if (typeof message.key !== 'string') return;
|
|
84
89
|
if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
|
|
85
90
|
window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
|
|
@@ -131,7 +136,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
|
|
|
131
136
|
seq += 1;
|
|
132
137
|
const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
|
|
133
138
|
window.parent.postMessage({
|
|
134
|
-
source: '
|
|
139
|
+
source: '${AX_SURFACE_EMIT_SOURCE}',
|
|
135
140
|
token: PMX_AX_TOKEN,
|
|
136
141
|
nodeId: PMX_AX_NODE_ID,
|
|
137
142
|
correlationId: correlationId,
|
|
@@ -152,7 +157,7 @@ export function buildAxBridge(axToken: string, nodeId: string): string {
|
|
|
152
157
|
};
|
|
153
158
|
window.addEventListener('message', function (event) {
|
|
154
159
|
const m = event.data;
|
|
155
|
-
if (!m || m.source !== '
|
|
160
|
+
if (!m || m.source !== '${AX_SURFACE_ACK_SOURCE}' || m.token !== PMX_AX_TOKEN) return;
|
|
156
161
|
const result = m.result || { ok: false };
|
|
157
162
|
const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
|
|
158
163
|
if (resolver) { pending.delete(m.correlationId); resolver(result); }
|
|
@@ -182,7 +187,7 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
|
|
|
182
187
|
window.PMX_AX.state = ${snapshotJson};
|
|
183
188
|
window.addEventListener('message', function (event) {
|
|
184
189
|
const m = event.data;
|
|
185
|
-
if (!m || m.source !== '
|
|
190
|
+
if (!m || m.source !== '${HTML_SURFACE_PUSH_SOURCE}' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
|
|
186
191
|
window.PMX_AX.state = m.state;
|
|
187
192
|
try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
|
|
188
193
|
});
|
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
|
*/
|
|
@@ -94,12 +79,6 @@ export interface CompositeToolDefinition {
|
|
|
94
79
|
* raise a loud error instead of silently no-op'ing.
|
|
95
80
|
*/
|
|
96
81
|
resolveOp?: (input: { kind: string; action: string }) => string | undefined;
|
|
97
|
-
/**
|
|
98
|
-
* Two-discriminator extension: human-readable `(kind, action)` for a member op
|
|
99
|
-
* (the inverse of `resolveOp`), used to build that op's deprecation note. The
|
|
100
|
-
* `kind` field-collision is resolved here (see `gateFieldRemap`).
|
|
101
|
-
*/
|
|
102
|
-
describeOp?: (opName: string) => { kind: string; action: string } | undefined;
|
|
103
82
|
/**
|
|
104
83
|
* Field-name remap applied to the composite's advertised schema and undone at
|
|
105
84
|
* dispatch. Resolves a collision between a discriminator name and a member-op
|
|
@@ -131,7 +110,7 @@ function resolveGateOp(input: { kind: string; action: string }): string | undefi
|
|
|
131
110
|
return undefined;
|
|
132
111
|
}
|
|
133
112
|
|
|
134
|
-
/** The 9 gate ops, in (kind, action) order — single source for memberOps
|
|
113
|
+
/** The 9 gate ops, in (kind, action) order — single source for memberOps. */
|
|
135
114
|
const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS.flatMap((kind) =>
|
|
136
115
|
GATE_ACTIONS.map((action) => {
|
|
137
116
|
const op = resolveGateOp({ kind, action });
|
|
@@ -143,12 +122,25 @@ const GATE_OPS: Array<{ op: string; kind: string; action: string }> = GATE_KINDS
|
|
|
143
122
|
}),
|
|
144
123
|
);
|
|
145
124
|
|
|
146
|
-
function describeGateOp(opName: string): { kind: string; action: string } | undefined {
|
|
147
|
-
const match = GATE_OPS.find((entry) => entry.op === opName);
|
|
148
|
-
return match ? { kind: match.kind, action: match.action } : undefined;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
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
|
+
},
|
|
152
144
|
{
|
|
153
145
|
toolName: 'canvas_node',
|
|
154
146
|
description:
|
|
@@ -289,7 +281,6 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
|
289
281
|
},
|
|
290
282
|
memberOps: GATE_OPS.map((entry) => entry.op),
|
|
291
283
|
resolveOp: resolveGateOp,
|
|
292
|
-
describeOp: describeGateOp,
|
|
293
284
|
// ax.approval.request defines its own optional `action` field (a
|
|
294
285
|
// machine-readable identifier the gate guards) — it collides with the gate
|
|
295
286
|
// lifecycle `action` discriminator. Namespace it to `approvalAction` in the
|
|
@@ -68,19 +68,26 @@ export class HttpOperationInvoker implements OperationInvoker {
|
|
|
68
68
|
const response = await fetch(url, init);
|
|
69
69
|
const text = await response.text();
|
|
70
70
|
let parsed: unknown = null;
|
|
71
|
+
let parsedAsJson = false;
|
|
71
72
|
if (text.length > 0) {
|
|
72
73
|
try {
|
|
73
74
|
parsed = JSON.parse(text) as unknown;
|
|
75
|
+
parsedAsJson = true;
|
|
74
76
|
} catch {
|
|
75
77
|
parsed = { error: text };
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
if (!response.ok) {
|
|
79
|
-
|
|
81
|
+
// errorBodyAsResult carries the route's STRUCTURED partial-failure
|
|
82
|
+
// envelope (e.g. canvas_batch { ok:false, failedIndex, ... }) through as
|
|
83
|
+
// a result. A non-JSON error body (an HTML 502 from a proxy, a foreign
|
|
84
|
+
// service) is not that envelope — fall through to the throw path so the
|
|
85
|
+
// caller still fails loudly with a nonzero exit.
|
|
86
|
+
if (route.errorBodyAsResult && parsedAsJson) return parsed;
|
|
80
87
|
const message =
|
|
81
|
-
parsed !== null && typeof parsed === 'object' && 'error' in parsed
|
|
88
|
+
parsedAsJson && parsed !== null && typeof parsed === 'object' && 'error' in parsed
|
|
82
89
|
? String((parsed as { error?: unknown }).error)
|
|
83
|
-
: `HTTP ${response.status}`;
|
|
90
|
+
: `HTTP ${response.status}${text.length > 0 ? `: ${text.slice(0, 200)}` : ''}`;
|
|
84
91
|
throw new OperationError(message, toOperationErrorStatus(response.status));
|
|
85
92
|
}
|
|
86
93
|
return parsed;
|
|
@@ -11,7 +11,7 @@ import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
|
|
|
11
11
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
12
|
import { getOperation, listOperations } from './registry.js';
|
|
13
13
|
import type { OperationInvoker } from './invoker.js';
|
|
14
|
-
import { OperationError, type OperationMcpToolHost } from './types.js';
|
|
14
|
+
import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
|
|
15
15
|
import { compositeFoldedOpNames, compositeToolDefinitions, type CompositeToolDefinition } from './composites.js';
|
|
16
16
|
|
|
17
17
|
export interface OperationToolHost extends OperationMcpToolHost {
|
|
@@ -176,6 +176,43 @@ function stripCompositeDiscriminators(
|
|
|
176
176
|
return out;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Per-op cache of the MCP arg names its extraShape marks REQUIRED (a schema
|
|
181
|
+
* that rejects `undefined`). These are the standalone tools' old contracts,
|
|
182
|
+
* which buildCompositeShape flattens to all-optional — the composite handler
|
|
183
|
+
* re-enforces them at dispatch so a missing required field fails loudly and
|
|
184
|
+
* names the field, instead of failing deep in the handler or silently
|
|
185
|
+
* no-oping (the `q`-vs-`query` zero-results trap from the 0.3.1 test report).
|
|
186
|
+
*/
|
|
187
|
+
const requiredFieldsCache = new WeakMap<object, string[]>();
|
|
188
|
+
|
|
189
|
+
// Advertised input aliases that satisfy a required extraShape field: the op's
|
|
190
|
+
// handler accepts either name, so the pre-dispatch check must too. (The only
|
|
191
|
+
// such collision across all 15 composites — edges.ts advertises `edge_id` as a
|
|
192
|
+
// legacy alias for `id` and its handler reads `edge_id ?? id`.)
|
|
193
|
+
const REQUIRED_FIELD_ALIASES: Record<string, Record<string, readonly string[]>> = {
|
|
194
|
+
'edge.remove': { id: ['edge_id'] },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
function requiredFieldSatisfied(opName: string, field: string, rest: Record<string, unknown>): boolean {
|
|
198
|
+
if (rest[field] !== undefined) return true;
|
|
199
|
+
const aliases = REQUIRED_FIELD_ALIASES[opName]?.[field] ?? [];
|
|
200
|
+
return aliases.some((alias) => rest[alias] !== undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requiredMcpFields(op: Operation): string[] {
|
|
204
|
+
const tool = op.mcp;
|
|
205
|
+
if (!tool?.extraShape) return [];
|
|
206
|
+
let keys = requiredFieldsCache.get(tool);
|
|
207
|
+
if (!keys) {
|
|
208
|
+
keys = Object.entries(tool.extraShape)
|
|
209
|
+
.filter(([, schema]) => !(schema as ZodTypeAny).safeParse(undefined).success)
|
|
210
|
+
.map(([key]) => key);
|
|
211
|
+
requiredFieldsCache.set(tool, keys);
|
|
212
|
+
}
|
|
213
|
+
return keys;
|
|
214
|
+
}
|
|
215
|
+
|
|
179
216
|
/**
|
|
180
217
|
* Register composite (action-discriminated) MCP tools (plan-006). Each action
|
|
181
218
|
* dispatches to a registered operation, reusing that op's `mcp.buildInput` and
|
|
@@ -188,6 +225,12 @@ export function registerCompositeTools(
|
|
|
188
225
|
definitions: CompositeToolDefinition[] = compositeToolDefinitions,
|
|
189
226
|
): void {
|
|
190
227
|
for (const def of definitions) {
|
|
228
|
+
// Public composite name for each op field (inverse of fieldRemap), so
|
|
229
|
+
// required-field errors name the argument the agent actually sends.
|
|
230
|
+
const publicFieldName = new Map<string, string>();
|
|
231
|
+
for (const [publicKey, opField] of Object.entries(def.fieldRemap ?? {})) {
|
|
232
|
+
publicFieldName.set(opField, publicKey);
|
|
233
|
+
}
|
|
191
234
|
server.tool(def.toolName, def.description, buildCompositeShape(def), async (input: Record<string, unknown>) => {
|
|
192
235
|
try {
|
|
193
236
|
const host = await getHost();
|
|
@@ -197,6 +240,13 @@ export function registerCompositeTools(
|
|
|
197
240
|
// and undo any field remap; the rest is the op's raw MCP args — the same
|
|
198
241
|
// value the standalone tool would receive.
|
|
199
242
|
const rest = stripCompositeDiscriminators(def, input);
|
|
243
|
+
const missing = requiredMcpFields(op).filter((field) => !requiredFieldSatisfied(opName, field, rest));
|
|
244
|
+
if (missing.length > 0) {
|
|
245
|
+
const named = missing.map((field) => `"${publicFieldName.get(field) ?? field}"`).join(', ');
|
|
246
|
+
throw new OperationError(
|
|
247
|
+
`${def.toolName} action "${String(input.action)}" requires ${named}. Unknown parameters are ignored — check the argument names.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
200
250
|
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
201
251
|
const result = await host.invoker().invoke(opName, opInput);
|
|
202
252
|
if (op.mcp?.formatResult) {
|
|
@@ -405,6 +405,11 @@ const webArtifactOperation = defineOperation<z.infer<typeof webArtifactSchema>,
|
|
|
405
405
|
http: {
|
|
406
406
|
method: 'POST',
|
|
407
407
|
path: '/api/canvas/web-artifact',
|
|
408
|
+
// Build failures throw OperationError, served as { ok:false, error }. The
|
|
409
|
+
// CLI prints that failure envelope on STDOUT and exits 1
|
|
410
|
+
// (cli-node.test.ts:2502-2548 pins it), so the HTTP invoker must return
|
|
411
|
+
// the non-2xx body instead of throwing — mirrors canvas.batch/webview.start.
|
|
412
|
+
errorBodyAsResult: true,
|
|
408
413
|
},
|
|
409
414
|
mcp: {
|
|
410
415
|
toolName: 'canvas_build_web_artifact',
|