pmx-canvas 0.1.36 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/Readme.md +2 -2
  3. package/dist/json-render/index.js +89 -334
  4. package/dist/types/mcp/canvas-access.d.ts +5 -171
  5. package/dist/types/server/ax-state-manager.d.ts +267 -0
  6. package/dist/types/server/ax-state.d.ts +3 -1
  7. package/dist/types/server/canvas-db.d.ts +13 -0
  8. package/dist/types/server/canvas-operations.d.ts +1 -12
  9. package/dist/types/server/canvas-state.d.ts +8 -23
  10. package/dist/types/server/index.d.ts +6 -24
  11. package/dist/types/server/operations/composites.d.ts +121 -0
  12. package/dist/types/server/operations/http.d.ts +7 -0
  13. package/dist/types/server/operations/index.d.ts +8 -0
  14. package/dist/types/server/operations/invoker.d.ts +13 -0
  15. package/dist/types/server/operations/mcp.d.ts +15 -0
  16. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  17. package/dist/types/server/operations/ops/app.d.ts +33 -0
  18. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  19. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  20. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  21. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  23. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  24. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  25. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  26. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  27. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  28. package/dist/types/server/operations/ops/query.d.ts +2 -0
  29. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  30. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  31. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  32. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  33. package/dist/types/server/operations/registry.d.ts +15 -0
  34. package/dist/types/server/operations/types.d.ts +116 -0
  35. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  36. package/docs/RELEASE.md +5 -0
  37. package/docs/adr-001-bun-only-runtime.md +46 -0
  38. package/docs/api-stability.md +57 -0
  39. package/docs/ax-host-adapter-contract.md +19 -1
  40. package/docs/ax-state-contract.md +72 -0
  41. package/docs/http-api.md +4 -0
  42. package/docs/mcp.md +61 -12
  43. package/docs/plans/plan-005-operation-registry.md +84 -0
  44. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  45. package/docs/plans/plan-007-ax-domain.md +99 -0
  46. package/docs/plans/plan-008-registry-finish.md +91 -0
  47. package/docs/tech-debt-assessment-2026-06.md +90 -0
  48. package/package.json +3 -3
  49. package/skills/pmx-canvas/SKILL.md +221 -193
  50. package/skills/pmx-canvas/evals/evals.json +3 -3
  51. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  52. package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
  53. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
  54. package/src/cli/agent.ts +52 -31
  55. package/src/mcp/canvas-access.ts +30 -830
  56. package/src/mcp/server.ts +162 -2014
  57. package/src/server/ax-context.ts +8 -1
  58. package/src/server/ax-state-manager.ts +826 -0
  59. package/src/server/ax-state.ts +10 -2
  60. package/src/server/canvas-db.ts +35 -0
  61. package/src/server/canvas-operations.ts +2 -328
  62. package/src/server/canvas-schema.ts +2 -2
  63. package/src/server/canvas-state.ts +103 -465
  64. package/src/server/index.ts +54 -190
  65. package/src/server/operations/composites.ts +355 -0
  66. package/src/server/operations/http.ts +103 -0
  67. package/src/server/operations/index.ts +65 -0
  68. package/src/server/operations/invoker.ts +87 -0
  69. package/src/server/operations/mcp.ts +221 -0
  70. package/src/server/operations/ops/annotation.ts +60 -0
  71. package/src/server/operations/ops/app.ts +447 -0
  72. package/src/server/operations/ops/ax-await.ts +216 -0
  73. package/src/server/operations/ops/ax-shared.ts +38 -0
  74. package/src/server/operations/ops/ax-state.ts +249 -0
  75. package/src/server/operations/ops/ax-timeline.ts +381 -0
  76. package/src/server/operations/ops/ax-work.ts +635 -0
  77. package/src/server/operations/ops/batch.ts +365 -0
  78. package/src/server/operations/ops/edges.ts +166 -0
  79. package/src/server/operations/ops/groups.ts +176 -0
  80. package/src/server/operations/ops/json-render.ts +691 -0
  81. package/src/server/operations/ops/nodes.ts +1047 -0
  82. package/src/server/operations/ops/query.ts +281 -0
  83. package/src/server/operations/ops/snapshots.ts +366 -0
  84. package/src/server/operations/ops/validate.ts +37 -0
  85. package/src/server/operations/ops/viewport.ts +219 -0
  86. package/src/server/operations/ops/webview.ts +339 -0
  87. package/src/server/operations/registry.ts +79 -0
  88. package/src/server/operations/types.ts +150 -0
  89. package/src/server/operations/webview-runner.ts +77 -0
  90. package/src/server/server.ts +158 -2255
  91. package/src/server/web-artifacts.ts +6 -2
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Slice 3 operations (plan-005, migration item 4): pin.set / search /
3
+ * history.get / canvas.undo / canvas.redo / spatial.get / summary.get.
4
+ *
5
+ * Event notes (matching the legacy HTTP handlers exactly):
6
+ * - pin.set: legacy POST /api/canvas/context-pins emitted ONLY
7
+ * context-pins-changed (no canvas-layout-update), so mutates: false with a
8
+ * manual ctx.emit. The injected emitter adds the sessionId/timestamp fields
9
+ * the legacy handler set explicitly. (The SDK's setContextPins emits a
10
+ * layout update instead — that local/remote drift is erased here in favor of
11
+ * the HTTP wire behavior.)
12
+ * - canvas.undo / canvas.redo: legacy handlers emitted canvas-viewport-update
13
+ * then canvas-layout-update only after an entry was actually undone/redone,
14
+ * so mutates: false with conditional manual emits. (The SDK's undo/redo also
15
+ * emit ax-state-changed; that was never part of the HTTP wire and the SDK
16
+ * methods are untouched.)
17
+ *
18
+ * This module must never import server.ts or index.ts.
19
+ */
20
+ import { z } from 'zod';
21
+ import { canvasState } from '../../canvas-state.js';
22
+ import {
23
+ setCanvasContextPins,
24
+ syncCanvasRuntimeBackends,
25
+ } from '../../canvas-operations.js';
26
+ import { buildCanvasSummary } from '../../canvas-serialization.js';
27
+ import { mutationHistory } from '../../mutation-history.js';
28
+ import { buildSpatialContext, searchNodes } from '../../spatial-analysis.js';
29
+ import { defineOperation, type Operation, type OperationMcpToolHost } from '../types.js';
30
+ import { isRecord } from './nodes.js';
31
+
32
+ // ── pin.set ───────────────────────────────────────────────────
33
+
34
+ /** Legacy server.ts handleContextPinsUpdate capped the requested list at 20. */
35
+ const MAX_PINS = 20;
36
+
37
+ const pinShape = {
38
+ nodeIds: z.unknown().optional().describe('Array of node IDs to pin'),
39
+ mode: z.unknown().optional().describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
40
+ };
41
+
42
+ const pinSchema = z.looseObject(pinShape);
43
+
44
+ const pinOperation = defineOperation<z.infer<typeof pinSchema>, Record<string, unknown>>({
45
+ name: 'pin.set',
46
+ mutates: false,
47
+ input: pinSchema,
48
+ inputShape: pinShape,
49
+ http: {
50
+ method: 'POST',
51
+ path: '/api/canvas/context-pins',
52
+ },
53
+ mcp: {
54
+ toolName: 'canvas_pin_nodes',
55
+ description: 'Pin nodes to include them in the agent context. Pinned nodes appear in the canvas://pinned-context resource. The human can also pin nodes by clicking in the browser.',
56
+ extraShape: {
57
+ nodeIds: z.array(z.string()).describe('Array of node IDs to pin'),
58
+ mode: z.enum(['set', 'add', 'remove']).optional()
59
+ .describe('set: replace all pins, add: add to existing pins, remove: unpin these nodes (default: set)'),
60
+ },
61
+ // The wire body is { ok, count } (legacy HTTP shape); the tool reports the
62
+ // resulting pin list, so re-read it from the host. (Legacy RemoteCanvasAccess
63
+ // computed the list client-side; the server state is authoritative now.)
64
+ formatResult: async (_result, _input, host) => ({
65
+ content: [{
66
+ type: 'text' as const,
67
+ text: JSON.stringify({ ok: true, pinnedNodeIds: await host.getPinnedNodeIds() }),
68
+ }],
69
+ }),
70
+ },
71
+ handler: (input, ctx) => {
72
+ const body: Record<string, unknown> = input;
73
+ const mode = body.mode === 'add' || body.mode === 'remove' ? body.mode : 'set';
74
+ const nodeIds = Array.isArray(body.nodeIds)
75
+ ? body.nodeIds.filter((id): id is string => typeof id === 'string')
76
+ : [];
77
+ // Legacy 'set' capped at MAX_PINS BEFORE setCanvasContextPins dedupes —
78
+ // replicated as-is. add/remove (formerly client-side in the MCP access
79
+ // layer) pass through; setCanvasContextPins normalizes them.
80
+ const result = setCanvasContextPins(mode === 'set' ? nodeIds.slice(0, MAX_PINS) : nodeIds, mode);
81
+ ctx.emit('context-pins-changed', { count: result.count, nodeIds: result.nodeIds });
82
+ return { ok: true, count: result.count };
83
+ },
84
+ });
85
+
86
+ // ── search ────────────────────────────────────────────────────
87
+
88
+ const searchShape = {
89
+ q: z.unknown().optional().describe('Search query — matches against node titles, content, and file paths'),
90
+ limit: z.unknown().optional().describe('Max results to return (default: 10). Applied by the MCP tool only; the HTTP route returns all matches.'),
91
+ };
92
+
93
+ const searchSchema = z.looseObject(searchShape);
94
+
95
+ const searchOperation = defineOperation<z.infer<typeof searchSchema>, Record<string, unknown>>({
96
+ name: 'search',
97
+ mutates: false,
98
+ input: searchSchema,
99
+ inputShape: searchShape,
100
+ http: {
101
+ method: 'GET',
102
+ path: '/api/canvas/search',
103
+ },
104
+ mcp: {
105
+ toolName: 'canvas_search',
106
+ description: 'Search for nodes by title or content keywords. Returns matching nodes ranked by relevance with snippets. Much faster than reading the full layout when you need to find specific nodes.',
107
+ extraShape: {
108
+ query: z.string().describe('Search query — matches against node titles, content, and file paths'),
109
+ limit: z.number().optional().describe('Max results to return (default: 10)'),
110
+ },
111
+ // The HTTP route only reads ?q= (legacy behavior); map the MCP-facing
112
+ // `query` arg onto it. The limit cap stays MCP-side, like the legacy tool.
113
+ buildInput: (input) => ({ q: typeof input.query === 'string' ? input.query : '' }),
114
+ formatResult: (result, input) => {
115
+ const body = isRecord(result) ? result : {};
116
+ const results = Array.isArray(body.results) ? body.results : [];
117
+ const limit = typeof input.limit === 'number' ? input.limit : 10;
118
+ return {
119
+ content: [{
120
+ type: 'text' as const,
121
+ text: JSON.stringify({
122
+ query: input.query,
123
+ resultCount: results.length,
124
+ results: results.slice(0, limit),
125
+ }, null, 2),
126
+ }],
127
+ };
128
+ },
129
+ },
130
+ handler: (input) => {
131
+ const q = typeof input.q === 'string' ? input.q : '';
132
+ if (!q.trim()) {
133
+ return { results: [], query: q };
134
+ }
135
+ return { results: searchNodes(canvasState.getLayout().nodes, q), query: q };
136
+ },
137
+ });
138
+
139
+ // ── history.get (HTTP/CLI only — canvas://history stays a resource) ──
140
+
141
+ const historyGetShape = {};
142
+
143
+ const historyGetSchema = z.looseObject(historyGetShape);
144
+
145
+ const historyGetOperation = defineOperation<z.infer<typeof historyGetSchema>, Record<string, unknown>>({
146
+ name: 'history.get',
147
+ mutates: false,
148
+ input: historyGetSchema,
149
+ inputShape: historyGetShape,
150
+ http: {
151
+ method: 'GET',
152
+ path: '/api/canvas/history',
153
+ },
154
+ handler: () => ({
155
+ text: mutationHistory.toHumanReadable(),
156
+ entries: mutationHistory.getSummaries(),
157
+ canUndo: mutationHistory.canUndo(),
158
+ canRedo: mutationHistory.canRedo(),
159
+ }),
160
+ });
161
+
162
+ // ── canvas.undo / canvas.redo ─────────────────────────────────
163
+
164
+ async function formatUndoRedoResult(result: unknown, host: OperationMcpToolHost) {
165
+ // Legacy MCP tools appended canUndo/canRedo from a follow-up history read.
166
+ const history = await host.invoker().invoke('history.get', {});
167
+ const historyBody = isRecord(history) ? history : {};
168
+ const body = isRecord(result) ? result : {};
169
+ return {
170
+ content: [{
171
+ type: 'text' as const,
172
+ text: JSON.stringify({ ...body, canUndo: historyBody.canUndo, canRedo: historyBody.canRedo }),
173
+ }],
174
+ };
175
+ }
176
+
177
+ const undoRedoShape = {};
178
+
179
+ const undoRedoSchema = z.looseObject(undoRedoShape);
180
+
181
+ const undoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<string, unknown>>({
182
+ name: 'canvas.undo',
183
+ mutates: false,
184
+ input: undoRedoSchema,
185
+ inputShape: undoRedoShape,
186
+ http: {
187
+ method: 'POST',
188
+ path: '/api/canvas/undo',
189
+ },
190
+ mcp: {
191
+ toolName: 'canvas_undo',
192
+ description: 'Undo the last canvas mutation. Returns a description of what was undone. Use this to backtrack when an approach is wrong — explore without fear.',
193
+ formatResult: (result, _input, host) => formatUndoRedoResult(result, host),
194
+ },
195
+ handler: async (_input, ctx) => {
196
+ const entry = mutationHistory.undo();
197
+ if (!entry) return { ok: false, description: 'Nothing to undo' };
198
+ await syncCanvasRuntimeBackends();
199
+ ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
200
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
201
+ return { ok: true, description: `Undid: ${entry.description}` };
202
+ },
203
+ });
204
+
205
+ const redoOperation = defineOperation<z.infer<typeof undoRedoSchema>, Record<string, unknown>>({
206
+ name: 'canvas.redo',
207
+ mutates: false,
208
+ input: undoRedoSchema,
209
+ inputShape: undoRedoShape,
210
+ http: {
211
+ method: 'POST',
212
+ path: '/api/canvas/redo',
213
+ },
214
+ mcp: {
215
+ toolName: 'canvas_redo',
216
+ description: 'Redo the last undone canvas mutation. Use after undo to re-apply a change.',
217
+ formatResult: (result, _input, host) => formatUndoRedoResult(result, host),
218
+ },
219
+ handler: async (_input, ctx) => {
220
+ const entry = mutationHistory.redo();
221
+ if (!entry) return { ok: false, description: 'Nothing to redo' };
222
+ await syncCanvasRuntimeBackends();
223
+ ctx.emit('canvas-viewport-update', { viewport: canvasState.viewport });
224
+ ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
225
+ return { ok: true, description: `Redid: ${entry.description}` };
226
+ },
227
+ });
228
+
229
+ // ── spatial.get (HTTP only — canvas://spatial-context stays a resource) ──
230
+
231
+ const spatialGetShape = {};
232
+
233
+ const spatialGetSchema = z.looseObject(spatialGetShape);
234
+
235
+ const spatialGetOperation = defineOperation<z.infer<typeof spatialGetSchema>, Record<string, unknown>>({
236
+ name: 'spatial.get',
237
+ mutates: false,
238
+ input: spatialGetSchema,
239
+ inputShape: spatialGetShape,
240
+ http: {
241
+ method: 'GET',
242
+ path: '/api/canvas/spatial-context',
243
+ },
244
+ handler: () => {
245
+ const layout = canvasState.getLayout();
246
+ return buildSpatialContext(
247
+ layout.nodes,
248
+ layout.edges,
249
+ canvasState.contextPinnedNodeIds,
250
+ layout.annotations,
251
+ ) as unknown as Record<string, unknown>;
252
+ },
253
+ });
254
+
255
+ // ── summary.get (HTTP only — canvas://summary stays a resource) ──
256
+
257
+ const summaryGetShape = {};
258
+
259
+ const summaryGetSchema = z.looseObject(summaryGetShape);
260
+
261
+ const summaryGetOperation = defineOperation<z.infer<typeof summaryGetSchema>, Record<string, unknown>>({
262
+ name: 'summary.get',
263
+ mutates: false,
264
+ input: summaryGetSchema,
265
+ inputShape: summaryGetShape,
266
+ http: {
267
+ method: 'GET',
268
+ path: '/api/canvas/summary',
269
+ },
270
+ handler: () => buildCanvasSummary() as unknown as Record<string, unknown>,
271
+ });
272
+
273
+ export const queryOperations: Operation[] = [
274
+ pinOperation,
275
+ searchOperation,
276
+ historyGetOperation,
277
+ undoOperation,
278
+ redoOperation,
279
+ spatialGetOperation,
280
+ summaryGetOperation,
281
+ ];
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Slice 3 operations (plan-005, migration item 5): snapshot.list /
3
+ * snapshot.save / snapshot.gc / snapshot.diff (+ query-param variant) /
4
+ * snapshot.restore / snapshot.delete.
5
+ *
6
+ * Wire notes:
7
+ * - snapshot.list serves the bare-array body (tests/e2e clearSnapshots reads
8
+ * GET /api/canvas/snapshots?all=true and expects an array, not an envelope).
9
+ * - snapshot.restore relies on the DEFERRED canvas-layout-update that
10
+ * restoreCanvasSnapshot itself schedules via setCanvasLayoutUpdateEmitter
11
+ * once async ext-app rehydration finishes. The handler must not emit that
12
+ * frame; `mutates: true` produces only the single immediate frame the legacy
13
+ * route broadcast.
14
+ * - Legacy save/restore/delete error responses were plain-text
15
+ * (responseText); they are now the registry's JSON `{ ok:false, error }`
16
+ * envelope (save's unreachable disk-failure case also moves 500 → 400).
17
+ *
18
+ * Route-order note: snapshot.gc (POST /api/canvas/snapshots/gc) MUST be
19
+ * registered before snapshot.restore (POST /api/canvas/snapshots/:id) — the
20
+ * dispatcher checks routes in registration order and ':id' would otherwise
21
+ * swallow the literal 'gc' segment, exactly like the legacy if-chain ordering.
22
+ *
23
+ * This module must never import server.ts or index.ts.
24
+ */
25
+ import { z } from 'zod';
26
+ import { canvasState } from '../../canvas-state.js';
27
+ import {
28
+ deleteCanvasSnapshot,
29
+ gcCanvasSnapshots,
30
+ listCanvasSnapshots,
31
+ restoreCanvasSnapshot,
32
+ saveCanvasSnapshot,
33
+ } from '../../canvas-operations.js';
34
+ import { diffLayouts, formatDiff } from '../../mutation-history.js';
35
+ import { defineOperation, OperationError, type Operation } from '../types.js';
36
+ import { isRecord } from './nodes.js';
37
+
38
+ /** Legacy server.ts parsePositiveIntegerParam, tolerant of in-process numbers. */
39
+ function parsePositiveIntegerParam(value: unknown): number | undefined {
40
+ if (typeof value !== 'string' && typeof value !== 'number') return undefined;
41
+ const text = String(value);
42
+ if (!text) return undefined;
43
+ const parsed = Number(text);
44
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
45
+ return Math.floor(parsed);
46
+ }
47
+
48
+ // ── snapshot.list ─────────────────────────────────────────────
49
+
50
+ const snapshotListShape = {
51
+ limit: z.unknown().optional().describe('Maximum snapshots to return (default: 20)'),
52
+ q: z.unknown().optional().describe('Query alias used by the HTTP/CLI surface'),
53
+ query: z.unknown().optional().describe('Optional case-insensitive ID/name filter'),
54
+ before: z.unknown().optional().describe('Only return snapshots created at or before this ISO timestamp'),
55
+ after: z.unknown().optional().describe('Only return snapshots created at or after this ISO timestamp'),
56
+ all: z.unknown().optional().describe('Return all snapshots instead of the default limit'),
57
+ };
58
+
59
+ const snapshotListSchema = z.looseObject(snapshotListShape);
60
+
61
+ const snapshotListOperation = defineOperation<z.infer<typeof snapshotListSchema>, unknown>({
62
+ name: 'snapshot.list',
63
+ mutates: false,
64
+ input: snapshotListSchema,
65
+ inputShape: snapshotListShape,
66
+ http: {
67
+ method: 'GET',
68
+ path: '/api/canvas/snapshots',
69
+ },
70
+ mcp: {
71
+ toolName: 'canvas_list_snapshots',
72
+ description: '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.',
73
+ extraShape: {
74
+ limit: z.number().optional().describe('Maximum snapshots to return (default: 20)'),
75
+ query: z.string().optional().describe('Optional case-insensitive ID/name filter'),
76
+ before: z.string().optional().describe('Only return snapshots created at or before this ISO timestamp'),
77
+ after: z.string().optional().describe('Only return snapshots created at or after this ISO timestamp'),
78
+ all: z.boolean().optional().describe('Return all snapshots instead of the default limit'),
79
+ },
80
+ formatResult: (result) => ({
81
+ content: [{ type: 'text' as const, text: JSON.stringify({ snapshots: result }, null, 2) }],
82
+ }),
83
+ },
84
+ handler: (input) => {
85
+ const body: Record<string, unknown> = input;
86
+ // Legacy precedence: ?q= wins over ?query= even when empty.
87
+ const query = typeof body.q === 'string'
88
+ ? body.q
89
+ : typeof body.query === 'string' ? body.query : undefined;
90
+ const limit = parsePositiveIntegerParam(body.limit);
91
+ return listCanvasSnapshots({
92
+ ...(limit !== undefined ? { limit } : {}),
93
+ ...(query !== undefined ? { query } : {}),
94
+ ...(typeof body.before === 'string' ? { before: body.before } : {}),
95
+ ...(typeof body.after === 'string' ? { after: body.after } : {}),
96
+ all: body.all === true || body.all === 'true',
97
+ });
98
+ },
99
+ });
100
+
101
+ // ── snapshot.save ─────────────────────────────────────────────
102
+
103
+ const snapshotSaveShape = {
104
+ name: z.unknown().optional().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
105
+ };
106
+
107
+ const snapshotSaveSchema = z.looseObject(snapshotSaveShape);
108
+
109
+ const snapshotSaveOperation = defineOperation<z.infer<typeof snapshotSaveSchema>, Record<string, unknown>>({
110
+ name: 'snapshot.save',
111
+ mutates: false,
112
+ input: snapshotSaveSchema,
113
+ inputShape: snapshotSaveShape,
114
+ http: {
115
+ method: 'POST',
116
+ path: '/api/canvas/snapshots',
117
+ },
118
+ mcp: {
119
+ toolName: 'canvas_snapshot',
120
+ description: 'Save the current canvas state as a named snapshot. Snapshots persist to disk and can be restored later.',
121
+ extraShape: {
122
+ name: z.string().describe('Name for this snapshot (e.g., "before refactor", "investigation v2")'),
123
+ },
124
+ formatResult: (result) => ({
125
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
126
+ }),
127
+ },
128
+ handler: (input) => {
129
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
130
+ if (!name) throw new OperationError('Missing snapshot name');
131
+ const snapshot = saveCanvasSnapshot(name);
132
+ if (!snapshot) throw new OperationError('Failed to save snapshot');
133
+ return { ok: true, id: snapshot.id, snapshot };
134
+ },
135
+ });
136
+
137
+ // ── snapshot.gc ───────────────────────────────────────────────
138
+
139
+ const snapshotGcShape = {
140
+ keep: z.unknown().optional().describe('Number of newest snapshots to keep (default: 20)'),
141
+ dryRun: z.unknown().optional().describe('Preview deletions without removing snapshot files'),
142
+ };
143
+
144
+ const snapshotGcSchema = z.looseObject(snapshotGcShape);
145
+
146
+ const snapshotGcOperation = defineOperation<z.infer<typeof snapshotGcSchema>, Record<string, unknown>>({
147
+ name: 'snapshot.gc',
148
+ mutates: false,
149
+ input: snapshotGcSchema,
150
+ inputShape: snapshotGcShape,
151
+ http: {
152
+ method: 'POST',
153
+ path: '/api/canvas/snapshots/gc',
154
+ },
155
+ mcp: {
156
+ toolName: 'canvas_gc_snapshots',
157
+ description: 'Delete old saved canvas snapshots, keeping the newest N snapshots. Use dryRun=true to preview deletions.',
158
+ extraShape: {
159
+ keep: z.number().optional().describe('Number of newest snapshots to keep (default: 20)'),
160
+ dryRun: z.boolean().optional().describe('Preview deletions without removing snapshot files'),
161
+ },
162
+ formatResult: (result) => ({
163
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
164
+ }),
165
+ },
166
+ handler: (input) => {
167
+ const body: Record<string, unknown> = input;
168
+ const keepValue = body.keep;
169
+ // Legacy coercion replicated as-is, including the Number('junk') → NaN
170
+ // pass-through (gcCanvasSnapshots falls back to its own default).
171
+ const keep = typeof keepValue === 'number'
172
+ ? keepValue
173
+ : typeof keepValue === 'string'
174
+ ? Number(keepValue)
175
+ : undefined;
176
+ const dryRun = body.dryRun === true || body['dry-run'] === true;
177
+ return gcCanvasSnapshots({
178
+ ...(keep !== undefined ? { keep } : {}),
179
+ dryRun,
180
+ }) as unknown as Record<string, unknown>;
181
+ },
182
+ });
183
+
184
+ // ── snapshot.diff ─────────────────────────────────────────────
185
+
186
+ function diffSnapshotCore(idOrName: string): Record<string, unknown> {
187
+ const snapshot = canvasState.getSnapshotData(idOrName);
188
+ if (!snapshot) throw new OperationError(`Snapshot "${idOrName}" not found.`, 404);
189
+ const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
190
+ return { ok: true, text: formatDiff(diff), diff };
191
+ }
192
+
193
+ const snapshotDiffShape = {
194
+ id: z.string().optional().catch(undefined).describe('Snapshot ID or name to compare against'),
195
+ };
196
+
197
+ const snapshotDiffSchema = z.looseObject(snapshotDiffShape);
198
+
199
+ const snapshotDiffOperation = defineOperation<z.infer<typeof snapshotDiffSchema>, Record<string, unknown>>({
200
+ name: 'snapshot.diff',
201
+ mutates: false,
202
+ input: snapshotDiffSchema,
203
+ inputShape: snapshotDiffShape,
204
+ http: {
205
+ method: 'GET',
206
+ path: '/api/canvas/snapshots/:id/diff',
207
+ },
208
+ mcp: {
209
+ toolName: 'canvas_diff',
210
+ description: 'Compare the current canvas state against a saved snapshot. Shows added/removed/modified nodes and edges. Pass either a snapshot name or ID.',
211
+ extraShape: {
212
+ snapshot: z.string().describe('Snapshot name or ID to compare against'),
213
+ },
214
+ buildInput: (input) => ({ id: typeof input.snapshot === 'string' ? input.snapshot : '' }),
215
+ // Legacy success output was the human-readable diff text, not JSON.
216
+ formatResult: (result) => {
217
+ const body = isRecord(result) ? result : {};
218
+ return {
219
+ content: [{ type: 'text' as const, text: typeof body.text === 'string' ? body.text : '' }],
220
+ };
221
+ },
222
+ },
223
+ handler: ({ id }) => diffSnapshotCore(id ?? ''),
224
+ });
225
+
226
+ // Query-param variant: GET /api/canvas/snapshots/diff?name=… (HTTP only).
227
+ const snapshotDiffQueryShape = {
228
+ name: z.unknown().optional().describe('Snapshot name to compare against'),
229
+ id: z.unknown().optional().describe('Snapshot ID alias for name'),
230
+ };
231
+
232
+ const snapshotDiffQuerySchema = z.looseObject(snapshotDiffQueryShape);
233
+
234
+ const snapshotDiffQueryOperation = defineOperation<z.infer<typeof snapshotDiffQuerySchema>, Record<string, unknown>>({
235
+ name: 'snapshot.diff.query',
236
+ mutates: false,
237
+ input: snapshotDiffQuerySchema,
238
+ inputShape: snapshotDiffQueryShape,
239
+ http: {
240
+ method: 'GET',
241
+ path: '/api/canvas/snapshots/diff',
242
+ },
243
+ handler: (input) => {
244
+ // Legacy precedence: ?name= wins over ?id= even when empty.
245
+ const name = typeof input.name === 'string'
246
+ ? input.name
247
+ : typeof input.id === 'string' ? input.id : '';
248
+ if (!name.trim()) throw new OperationError('Missing snapshot name or id.');
249
+ return diffSnapshotCore(name);
250
+ },
251
+ });
252
+
253
+ // ── snapshot.restore ──────────────────────────────────────────
254
+
255
+ /** Legacy mcp/server.ts buildSnapshotRestoreSummary, fed from the wire layout. */
256
+ function buildSnapshotRestoreSummary(layout: unknown): Record<string, unknown> {
257
+ const body = isRecord(layout) ? layout : {};
258
+ const nodes = Array.isArray(body.nodes) ? body.nodes : [];
259
+ const edges = Array.isArray(body.edges) ? body.edges : [];
260
+ const annotations = Array.isArray(body.annotations) ? body.annotations : [];
261
+ const nodesByType: Record<string, number> = {};
262
+ for (const node of nodes) {
263
+ const type = isRecord(node) && typeof node.type === 'string' ? node.type : 'unknown';
264
+ nodesByType[type] = (nodesByType[type] ?? 0) + 1;
265
+ }
266
+ return {
267
+ nodeCount: nodes.length,
268
+ edgeCount: edges.length,
269
+ annotationCount: annotations.length,
270
+ nodesByType,
271
+ viewport: body.viewport,
272
+ };
273
+ }
274
+
275
+ const snapshotRestoreShape = {
276
+ id: z.string().optional().catch(undefined).describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
277
+ };
278
+
279
+ const snapshotRestoreSchema = z.looseObject(snapshotRestoreShape);
280
+
281
+ const snapshotRestoreOperation = defineOperation<z.infer<typeof snapshotRestoreSchema>, Record<string, unknown>>({
282
+ name: 'snapshot.restore',
283
+ mutates: true,
284
+ input: snapshotRestoreSchema,
285
+ inputShape: snapshotRestoreShape,
286
+ http: {
287
+ method: 'POST',
288
+ path: '/api/canvas/snapshots/:id',
289
+ },
290
+ mcp: {
291
+ toolName: 'canvas_restore',
292
+ description: 'Restore the canvas to a previously saved snapshot. Use canvas_snapshot to save first. Pass either the snapshot ID or name to restore.',
293
+ extraShape: {
294
+ id: z.string().describe('Snapshot ID or name to restore (from canvas_snapshot or snapshot list)'),
295
+ },
296
+ formatResult: async (_result, input, host) => {
297
+ const layout = await host.invoker().invoke('layout.get', { includeBlobs: true });
298
+ return {
299
+ content: [{
300
+ type: 'text' as const,
301
+ text: JSON.stringify({
302
+ ok: true,
303
+ restored: input.id,
304
+ summary: buildSnapshotRestoreSummary(layout),
305
+ }, null, 2),
306
+ }],
307
+ };
308
+ },
309
+ },
310
+ handler: async ({ id }) => {
311
+ // Awaits only the synchronous restore; restoreCanvasSnapshot fires the
312
+ // async ext-app rehydration itself and schedules a deferred
313
+ // canvas-layout-update through setCanvasLayoutUpdateEmitter when it
314
+ // finishes. mutates: true contributes the single immediate frame — do NOT
315
+ // add a manual emit here or the frame is doubled.
316
+ const result = await restoreCanvasSnapshot(id ?? '');
317
+ if (!result.ok) throw new OperationError('Snapshot not found', 404);
318
+ return { ok: true };
319
+ },
320
+ });
321
+
322
+ // ── snapshot.delete ───────────────────────────────────────────
323
+
324
+ const snapshotDeleteShape = {
325
+ id: z.string().optional().catch(undefined).describe('Snapshot ID to delete'),
326
+ };
327
+
328
+ const snapshotDeleteSchema = z.looseObject(snapshotDeleteShape);
329
+
330
+ const snapshotDeleteOperation = defineOperation<z.infer<typeof snapshotDeleteSchema>, Record<string, unknown>>({
331
+ name: 'snapshot.delete',
332
+ mutates: false,
333
+ input: snapshotDeleteSchema,
334
+ inputShape: snapshotDeleteShape,
335
+ http: {
336
+ method: 'DELETE',
337
+ path: '/api/canvas/snapshots/:id',
338
+ },
339
+ mcp: {
340
+ toolName: 'canvas_delete_snapshot',
341
+ description: 'Delete a saved snapshot by ID.',
342
+ extraShape: {
343
+ id: z.string().describe('Snapshot ID to delete'),
344
+ },
345
+ formatResult: (_result, input) => ({
346
+ content: [{ type: 'text' as const, text: JSON.stringify({ ok: true, deleted: input.id }) }],
347
+ }),
348
+ },
349
+ handler: ({ id }) => {
350
+ const result = deleteCanvasSnapshot(id ?? '');
351
+ if (!result.ok) throw new OperationError('Snapshot not found', 404);
352
+ return { ok: true };
353
+ },
354
+ });
355
+
356
+ export const snapshotOperations: Operation[] = [
357
+ snapshotListOperation,
358
+ snapshotSaveOperation,
359
+ // gc before restore: POST /api/canvas/snapshots/gc must match before
360
+ // POST /api/canvas/snapshots/:id (registration order = dispatch order).
361
+ snapshotGcOperation,
362
+ snapshotDiffQueryOperation,
363
+ snapshotDiffOperation,
364
+ snapshotRestoreOperation,
365
+ snapshotDeleteOperation,
366
+ ];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Board validation read op (plan-008 Wave 1): validate.get.
3
+ *
4
+ * Pure read — no mutation, no emit. Serializes the same shape the legacy
5
+ * GET /api/canvas/validate handler and the canvas_validate MCP tool returned.
6
+ *
7
+ * This module must never import server.ts or index.ts.
8
+ */
9
+ import { z } from 'zod';
10
+ import { canvasState } from '../../canvas-state.js';
11
+ import { validateCanvasLayout, type CanvasValidationResult } from '../../canvas-validation.js';
12
+ import { defineOperation, type Operation } from '../types.js';
13
+
14
+ const validateGetShape = {};
15
+
16
+ const validateGetSchema = z.looseObject(validateGetShape);
17
+
18
+ const validateGetOperation = defineOperation<z.infer<typeof validateGetSchema>, CanvasValidationResult>({
19
+ name: 'validate.get',
20
+ mutates: false,
21
+ input: validateGetSchema,
22
+ inputShape: validateGetShape,
23
+ http: {
24
+ method: 'GET',
25
+ path: '/api/canvas/validate',
26
+ },
27
+ mcp: {
28
+ toolName: 'canvas_validate',
29
+ description: 'Validate the current canvas layout. Distinguishes true node collisions from expected group-child containment and reports missing edge endpoints.',
30
+ formatResult: (result) => ({
31
+ content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
32
+ }),
33
+ },
34
+ handler: () => validateCanvasLayout(canvasState.getLayout()),
35
+ });
36
+
37
+ export const validateOperations: Operation[] = [validateGetOperation];