pmx-canvas 0.1.35 → 0.2.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 +461 -0
- package/Readme.md +14 -2
- package/dist/canvas/index.js +82 -41
- package/dist/json-render/index.js +89 -334
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +12 -159
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/ax-state-manager.d.ts +256 -0
- package/dist/types/server/ax-state.d.ts +29 -1
- package/dist/types/server/ax-wait.d.ts +23 -0
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +46 -14
- package/dist/types/server/html-surface.d.ts +7 -0
- package/dist/types/server/index.d.ts +66 -26
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-host-adapter-contract.md +65 -0
- package/docs/ax-state-contract.md +72 -0
- package/docs/http-api.md +34 -2
- package/docs/mcp.md +64 -11
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +233 -185
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
- package/src/cli/agent.ts +52 -31
- package/src/client/nodes/ExtAppFrame.tsx +73 -5
- package/src/client/nodes/HtmlNode.tsx +12 -3
- package/src/client/nodes/McpAppNode.tsx +12 -3
- package/src/json-render/renderer/index.tsx +3 -0
- package/src/mcp/canvas-access.ts +43 -774
- package/src/mcp/server.ts +190 -2001
- package/src/server/ax-context.ts +7 -1
- package/src/server/ax-state-manager.ts +808 -0
- package/src/server/ax-state.ts +89 -2
- package/src/server/ax-wait.ts +56 -0
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +140 -382
- package/src/server/html-surface.ts +49 -11
- package/src/server/index.ts +136 -192
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +253 -2170
- package/src/server/web-artifacts.ts +6 -2
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch meta-operation (plan-005 item 9, the last registry slice).
|
|
3
|
+
*
|
|
4
|
+
* `canvas.batch` dispatches each entry through `executeOperation` — the same
|
|
5
|
+
* single execution path every other surface uses — instead of the former
|
|
6
|
+
* ~290-line hand-written switch in canvas-operations.ts.
|
|
7
|
+
*
|
|
8
|
+
* Frame-count parity (operation-parity counts SSE frames): the legacy batch
|
|
9
|
+
* called the core functions directly and emitted NOTHING per entry; the HTTP
|
|
10
|
+
* handler / SDK fired exactly ONE final `canvas-layout-update`. Routing entries
|
|
11
|
+
* through `executeOperation` would instead fire each op's auto layout emit plus
|
|
12
|
+
* each handler's `ctx.emit`. To preserve the single-final-frame behavior we run
|
|
13
|
+
* the whole loop inside `runWithSuppressedEmits` (registry-level depth-counted
|
|
14
|
+
* suppression) and emit ONE `canvas-layout-update` manually after the loop.
|
|
15
|
+
*
|
|
16
|
+
* Result-shape parity (tests assert results[i].id/url/data/etc.): each op's
|
|
17
|
+
* HTTP `serialize` is the WIRE shape, which differs from what the legacy switch
|
|
18
|
+
* pushed (the legacy switch compact-serialized created nodes and shaped each
|
|
19
|
+
* entry by hand). `shapeBatchEntry` re-derives the byte-identical legacy shape
|
|
20
|
+
* from the produced node + the op result's non-node extras, so HTTP/CLI/MCP
|
|
21
|
+
* batch results are unchanged.
|
|
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
|
+
serializeCanvasNode,
|
|
29
|
+
serializeCanvasNodeCompact,
|
|
30
|
+
} from '../../canvas-serialization.js';
|
|
31
|
+
import { addCanvasNode } from '../../canvas-operations.js';
|
|
32
|
+
import { executeOperation, runWithSuppressedEmits } from '../registry.js';
|
|
33
|
+
import { defineOperation, type Operation, type OperationContext } from '../types.js';
|
|
34
|
+
import { readJsonValue } from '../http.js';
|
|
35
|
+
import { isRecord } from './nodes.js';
|
|
36
|
+
|
|
37
|
+
interface BatchEntry {
|
|
38
|
+
op: string;
|
|
39
|
+
assign?: string;
|
|
40
|
+
args: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BatchEnvelope {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
results: Array<Record<string, unknown>>;
|
|
46
|
+
refs: Record<string, unknown>;
|
|
47
|
+
failedIndex?: number;
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SUPPORTED_BATCH_OPS = new Set([
|
|
52
|
+
'node.add',
|
|
53
|
+
'node.update',
|
|
54
|
+
'node.remove',
|
|
55
|
+
'graph.add',
|
|
56
|
+
'edge.add',
|
|
57
|
+
'group.create',
|
|
58
|
+
'group.add',
|
|
59
|
+
'group.remove',
|
|
60
|
+
'pin.set',
|
|
61
|
+
'pin.add',
|
|
62
|
+
'pin.remove',
|
|
63
|
+
'snapshot.save',
|
|
64
|
+
'arrange',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `$ref` / `$ref.path` placeholders in batch args against the running
|
|
69
|
+
* refs map. Identical to the legacy resolveBatchRefs: a bare `$name` resolves to
|
|
70
|
+
* the assigned result's `id`; `$name.path.to.field` walks the stored object.
|
|
71
|
+
*/
|
|
72
|
+
function resolveBatchRefs(value: unknown, refs: Record<string, unknown>): unknown {
|
|
73
|
+
if (typeof value === 'string' && value.startsWith('$')) {
|
|
74
|
+
const path = value.slice(1).split('.');
|
|
75
|
+
let current: unknown = refs[path[0] ?? ''];
|
|
76
|
+
if (path.length === 1 && isRecord(current) && typeof current.id === 'string') return current.id;
|
|
77
|
+
for (const segment of path.slice(1)) {
|
|
78
|
+
if (!isRecord(current) && !Array.isArray(current)) return undefined;
|
|
79
|
+
current = (current as Record<string, unknown>)[segment];
|
|
80
|
+
}
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
if (Array.isArray(value)) return value.map((item) => resolveBatchRefs(item, refs));
|
|
84
|
+
if (isRecord(value)) {
|
|
85
|
+
const resolved: Record<string, unknown> = {};
|
|
86
|
+
for (const [key, child] of Object.entries(value)) {
|
|
87
|
+
resolved[key] = resolveBatchRefs(child, refs);
|
|
88
|
+
}
|
|
89
|
+
return resolved;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Normalize raw operations (legacy handleCanvasBatch normalization). */
|
|
95
|
+
function normalizeOperations(raw: unknown): BatchEntry[] {
|
|
96
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
97
|
+
return list
|
|
98
|
+
.filter((operation): operation is Record<string, unknown> => isRecord(operation))
|
|
99
|
+
.map((operation) => ({
|
|
100
|
+
op: String(operation.op ?? ''),
|
|
101
|
+
...(typeof operation.assign === 'string' ? { assign: operation.assign } : {}),
|
|
102
|
+
args: isRecord(operation.args) ? operation.args : {},
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Map a batch op-name + args onto the registered op name + invocation args.
|
|
108
|
+
* The legacy switch accepted `pin.add` / `pin.remove` as op-name aliases (the
|
|
109
|
+
* mode was derived from the name); the registry has a single `pin.set` op with
|
|
110
|
+
* a `mode` arg, so inject the mode here before dispatch.
|
|
111
|
+
*/
|
|
112
|
+
function resolveDispatch(op: string, args: Record<string, unknown>): { name: string; args: Record<string, unknown> } {
|
|
113
|
+
if (op === 'pin.add') return { name: 'pin.set', args: { ...args, mode: 'add' } };
|
|
114
|
+
if (op === 'pin.remove') return { name: 'pin.set', args: { ...args, mode: 'remove' } };
|
|
115
|
+
if (op === 'pin.set') return { name: 'pin.set', args: { ...args, mode: 'set' } };
|
|
116
|
+
return { name: op, args };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Ops that create/return a node and whose batch entry is the compact node payload
|
|
120
|
+
// + op-specific extras (url/spec/fetch). Keep this to the legacy batch allowlist;
|
|
121
|
+
// accepting arbitrary registered node-producing ops would expand the batch
|
|
122
|
+
// contract and can bypass the single-final-SSE invariant.
|
|
123
|
+
const NODE_PRODUCING_OPS = new Set(['node.add', 'node.update', 'group.create', 'graph.add']);
|
|
124
|
+
|
|
125
|
+
function isInternalBatchNodeType(type: string): type is 'prompt' | 'response' {
|
|
126
|
+
return type === 'prompt' || type === 'response';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createInternalBatchNode(args: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
+
const type = typeof args.type === 'string' ? args.type : '';
|
|
131
|
+
if (!isInternalBatchNodeType(type)) {
|
|
132
|
+
throw new Error(`Unsupported internal canvas_batch node type "${type}".`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const data = isRecord(args.data) ? args.data : {};
|
|
136
|
+
const created = addCanvasNode({
|
|
137
|
+
type,
|
|
138
|
+
...(typeof args.title === 'string' ? { title: args.title } : {}),
|
|
139
|
+
...(typeof args.content === 'string' ? { content: args.content } : {}),
|
|
140
|
+
...(Object.keys(data).length > 0 ? { data } : {}),
|
|
141
|
+
...(typeof args.x === 'number' ? { x: args.x } : {}),
|
|
142
|
+
...(typeof args.y === 'number' ? { y: args.y } : {}),
|
|
143
|
+
...(typeof args.width === 'number' ? { width: args.width } : {}),
|
|
144
|
+
...(typeof args.height === 'number' ? { height: args.height } : {}),
|
|
145
|
+
...(args.strictSize === true ? { strictSize: true } : {}),
|
|
146
|
+
defaultWidth: 360,
|
|
147
|
+
defaultHeight: 200,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { ok: true, ...serializeCanvasNodeCompact(created.node) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Re-shape an `executeOperation` result into the byte-identical legacy batch
|
|
155
|
+
* entry shape. Most ops match the wire shape verbatim; the cases below diverge
|
|
156
|
+
* because the legacy switch shaped them by hand.
|
|
157
|
+
*/
|
|
158
|
+
function shapeBatchEntry(op: string, result: unknown): Record<string, unknown> {
|
|
159
|
+
const body = isRecord(result) ? result : {};
|
|
160
|
+
|
|
161
|
+
// node.add / node.update / group.create / graph.add: the legacy switch pushed
|
|
162
|
+
// { ok:true, ...serializeCanvasNodeCompact(node), <extras> }. The wire shape
|
|
163
|
+
// adds `node` / `nodeId` and uses the FULL (non-compact) serialization. Re-derive
|
|
164
|
+
// the compact node payload from state and merge the op-specific non-node extras
|
|
165
|
+
// (webpage fetch/error, graph url/spec) the wire shape carries alongside it.
|
|
166
|
+
if (NODE_PRODUCING_OPS.has(op)) {
|
|
167
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
168
|
+
const node = id ? canvasState.getNode(id) : undefined;
|
|
169
|
+
const extras: Record<string, unknown> = {};
|
|
170
|
+
for (const key of ['fetch', 'error', 'url', 'spec']) {
|
|
171
|
+
if (body[key] !== undefined) extras[key] = body[key];
|
|
172
|
+
}
|
|
173
|
+
if (node) return { ok: true, ...serializeCanvasNodeCompact(node), ...extras };
|
|
174
|
+
// node.update of a now-missing node fell back to { ok:true, id } in the legacy switch.
|
|
175
|
+
return op === 'node.update' && id ? { ok: true, id } : body;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// node.remove: legacy pushed { ok:true, id, removed:true }; wire shape is { ok:true, removed:<id> }.
|
|
179
|
+
if (op === 'node.remove') {
|
|
180
|
+
const id = typeof body.removed === 'string' ? body.removed : '';
|
|
181
|
+
return { ok: true, id, removed: true };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// group.add: legacy pushed the serialized group node; wire shape is { ok:true, groupId }.
|
|
185
|
+
if (op === 'group.add') {
|
|
186
|
+
const groupId = typeof body.groupId === 'string' ? body.groupId : '';
|
|
187
|
+
const group = groupId ? canvasState.getNode(groupId) : undefined;
|
|
188
|
+
return { ok: true, ...(group ? serializeCanvasNode(group) : { id: groupId }) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// pin.set/add/remove: legacy pushed { ok:true, ...{ count, nodeIds } }; wire shape drops nodeIds.
|
|
192
|
+
if (op === 'pin.set' || op === 'pin.add' || op === 'pin.remove') {
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
count: canvasState.contextPinnedNodeIds.size,
|
|
196
|
+
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// snapshot.save: legacy pushed { ok:true, snapshot }; wire shape adds `id`.
|
|
201
|
+
if (op === 'snapshot.save') {
|
|
202
|
+
return { ok: true, snapshot: body.snapshot };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// arrange: legacy pushed { ok:true, ...{ arranged, layout } } and never validated.
|
|
206
|
+
if (op === 'arrange') {
|
|
207
|
+
return { ok: true, arranged: body.arranged, layout: body.layout };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// edge.add / group.remove: wire shape already matches the legacy push verbatim.
|
|
211
|
+
return body;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
215
|
+
const refs: Record<string, unknown> = {};
|
|
216
|
+
const results: Array<Record<string, unknown>> = [];
|
|
217
|
+
|
|
218
|
+
for (let index = 0; index < operations.length; index++) {
|
|
219
|
+
const operation = operations[index]!;
|
|
220
|
+
const args = resolveBatchRefs(operation.args, refs);
|
|
221
|
+
if (!isRecord(args)) {
|
|
222
|
+
return { ok: false, failedIndex: index, error: `Operation ${index} has invalid args.`, results, refs };
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (!SUPPORTED_BATCH_OPS.has(operation.op)) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
failedIndex: index,
|
|
229
|
+
error: `Unsupported canvas_batch operation "${operation.op}".`,
|
|
230
|
+
results,
|
|
231
|
+
refs,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (operation.op === 'node.add' && typeof args.type === 'string' && isInternalBatchNodeType(args.type)) {
|
|
235
|
+
const result = createInternalBatchNode(args);
|
|
236
|
+
results.push(result);
|
|
237
|
+
if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
|
|
238
|
+
refs[operation.assign] = result;
|
|
239
|
+
}
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const dispatch = resolveDispatch(operation.op, args);
|
|
243
|
+
const raw = await executeOperation(dispatch.name, dispatch.args);
|
|
244
|
+
const result = shapeBatchEntry(operation.op, raw);
|
|
245
|
+
results.push(result);
|
|
246
|
+
if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
|
|
247
|
+
refs[operation.assign] = result;
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
failedIndex: index,
|
|
253
|
+
error: error instanceof Error ? error.message : String(error),
|
|
254
|
+
results,
|
|
255
|
+
refs,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { ok: true, results, refs };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const batchShape = {
|
|
264
|
+
operations: z.array(z.object({
|
|
265
|
+
op: z.string().describe('Operation name, e.g. "node.add" or "edge.add"'),
|
|
266
|
+
assign: z.string().optional().describe('Optional reference name for later operations'),
|
|
267
|
+
args: z.record(z.string(), z.unknown()).optional().describe('Operation arguments'),
|
|
268
|
+
})).describe('Ordered array of batch operations'),
|
|
269
|
+
full: z.boolean().optional().describe('Return full batch operation results. Default false compacts node-like payloads.'),
|
|
270
|
+
verbose: z.boolean().optional().describe('Alias for full:true.'),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const batchSchema = z.looseObject({
|
|
274
|
+
operations: z.unknown().optional(),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
function wantsFullBatch(input: { full?: boolean; verbose?: boolean } = {}): boolean {
|
|
278
|
+
return input.full === true || input.verbose === true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* MCP compaction (moved verbatim from mcp/server.ts): drop verbose node fields
|
|
283
|
+
* from node-like batch entries unless `full`/`verbose` is set.
|
|
284
|
+
*/
|
|
285
|
+
function compactBatchValue(value: unknown): unknown {
|
|
286
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
287
|
+
const record = value as Record<string, unknown>;
|
|
288
|
+
const nodeLike = typeof record.id === 'string' && typeof record.type === 'string';
|
|
289
|
+
const compact: Record<string, unknown> = {};
|
|
290
|
+
for (const key of ['ok', 'id', 'type', 'kind', 'title', 'content', 'position', 'size', 'fetch', 'error', 'from', 'to', 'groupId', 'nodeIds', 'snapshot', 'arranged', 'layout']) {
|
|
291
|
+
if (record[key] !== undefined) compact[key] = record[key];
|
|
292
|
+
}
|
|
293
|
+
if (nodeLike) return compact;
|
|
294
|
+
return record;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function compactBatchResult(result: BatchEnvelope): Record<string, unknown> {
|
|
298
|
+
return {
|
|
299
|
+
ok: result.ok,
|
|
300
|
+
...(result.failedIndex !== undefined ? { failedIndex: result.failedIndex } : {}),
|
|
301
|
+
...(result.error ? { error: result.error } : {}),
|
|
302
|
+
results: result.results.map((entry) => compactBatchValue(entry)),
|
|
303
|
+
refs: Object.fromEntries(Object.entries(result.refs).map(([key, value]) => [key, compactBatchValue(value)])),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelope>({
|
|
308
|
+
name: 'canvas.batch',
|
|
309
|
+
// mutates:false — the registry must NOT auto-emit. The handler emits ONE final
|
|
310
|
+
// canvas-layout-update via ctx.emit (outside the per-entry suppression window)
|
|
311
|
+
// so the frame fires once whether or not any entry mutated.
|
|
312
|
+
mutates: false,
|
|
313
|
+
input: batchSchema,
|
|
314
|
+
inputShape: batchShape,
|
|
315
|
+
http: {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
path: '/api/canvas/batch',
|
|
318
|
+
status: (result) => (isRecord(result) && result.ok === false ? 400 : 200),
|
|
319
|
+
// Remote MCP callers still need the structured partial-failure envelope so
|
|
320
|
+
// the canvas_batch formatter can return JSON with isError=true.
|
|
321
|
+
errorBodyAsResult: true,
|
|
322
|
+
// Preserve BOTH documented body shapes: { operations:[...] } and a bare
|
|
323
|
+
// [...] array (the array-preserving reader never coerces — see plan-005).
|
|
324
|
+
readInput: async (req) => {
|
|
325
|
+
const body = await readJsonValue(req);
|
|
326
|
+
const operations = Array.isArray(body)
|
|
327
|
+
? body
|
|
328
|
+
: isRecord(body) && Array.isArray(body.operations) ? body.operations : [];
|
|
329
|
+
return { operations };
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
mcp: {
|
|
333
|
+
toolName: 'canvas_batch',
|
|
334
|
+
description: 'Run a non-atomic batch of canvas operations with optional assigned references. Use assign to name a result, then reference it later as "$name" for the created node id or "$name.id" for a specific result field. On failure, earlier successful operations remain applied and the response includes ok:false, failedIndex, error, results, and refs. Supports node.add, node.update, node.remove, graph.add, edge.add, group.create, group.add, group.remove, pin.set/add/remove, snapshot.save, and arrange.',
|
|
335
|
+
formatResult: (result, input) => {
|
|
336
|
+
const envelope = result as BatchEnvelope;
|
|
337
|
+
const payload = wantsFullBatch(input) ? envelope : compactBatchResult(envelope);
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
|
340
|
+
...(envelope.ok ? {} : { isError: true }),
|
|
341
|
+
};
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
handler: async (input, ctx: OperationContext) => {
|
|
345
|
+
const operations = normalizeOperations(input.operations);
|
|
346
|
+
// Suppress every per-entry SSE frame (auto layout emit + each handler's
|
|
347
|
+
// ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
|
|
348
|
+
const envelope = await runWithSuppressedEmits(() => runBatch(operations));
|
|
349
|
+
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
350
|
+
return envelope;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
export const batchOperations: Operation[] = [batchOperation];
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Typed SDK entry point: run a batch through the registry's single execution
|
|
358
|
+
* path (`executeOperation('canvas.batch')`). The op emits the one final
|
|
359
|
+
* canvas-layout-update itself, so callers must NOT emit again.
|
|
360
|
+
*/
|
|
361
|
+
export async function runCanvasBatchOperation(
|
|
362
|
+
operations: Array<{ op: string; assign?: string; args?: Record<string, unknown> }>,
|
|
363
|
+
): Promise<BatchEnvelope> {
|
|
364
|
+
return await executeOperation('canvas.batch', { operations }) as BatchEnvelope;
|
|
365
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2 operations (plan-005): edge.add / edge.remove.
|
|
3
|
+
*
|
|
4
|
+
* This module must never import server.ts or index.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import type { CanvasEdge } from '../../canvas-state.js';
|
|
8
|
+
import { addCanvasEdge, removeCanvasEdge } from '../../canvas-operations.js';
|
|
9
|
+
import { readJsonValue } from '../http.js';
|
|
10
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
11
|
+
import { isRecord } from './nodes.js';
|
|
12
|
+
|
|
13
|
+
const VALID_EDGE_TYPES = new Set(['relation', 'depends-on', 'flow', 'references']);
|
|
14
|
+
const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
|
|
15
|
+
|
|
16
|
+
// ── edge.add ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const edgeAddShape = {
|
|
19
|
+
from: z.string().optional().catch(undefined).describe('Source node ID'),
|
|
20
|
+
to: z.string().optional().catch(undefined).describe('Target node ID'),
|
|
21
|
+
fromSearch: z.string().optional().catch(undefined).describe('Resolve the source node by exact or fuzzy title/content search'),
|
|
22
|
+
toSearch: z.string().optional().catch(undefined).describe('Resolve the target node by exact or fuzzy title/content search'),
|
|
23
|
+
type: z.unknown().optional().describe('Edge type: flow, depends-on, relation, or references'),
|
|
24
|
+
label: z.unknown().optional().describe('Edge label text'),
|
|
25
|
+
style: z.unknown().optional().describe('Optional edge stroke style: solid, dashed, or dotted'),
|
|
26
|
+
animated: z.unknown().optional().describe('Animate the edge stroke'),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const edgeAddSchema = z.looseObject(edgeAddShape);
|
|
30
|
+
|
|
31
|
+
const edgeAddOperation = defineOperation<z.infer<typeof edgeAddSchema>, CanvasEdge>({
|
|
32
|
+
name: 'edge.add',
|
|
33
|
+
mutates: true,
|
|
34
|
+
input: edgeAddSchema,
|
|
35
|
+
inputShape: edgeAddShape,
|
|
36
|
+
http: {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
path: '/api/canvas/edge',
|
|
39
|
+
},
|
|
40
|
+
mcp: {
|
|
41
|
+
toolName: 'canvas_add_edge',
|
|
42
|
+
description: 'Add an edge (connection) between two nodes. Edge types: flow (sequential), depends-on (dependency), relation (general), references (cross-reference).',
|
|
43
|
+
extraShape: {
|
|
44
|
+
type: z.enum(['flow', 'depends-on', 'relation', 'references']).describe('Edge type'),
|
|
45
|
+
label: z.string().optional().describe('Edge label text'),
|
|
46
|
+
style: z.enum(['solid', 'dashed', 'dotted']).optional().describe('Optional edge stroke style'),
|
|
47
|
+
animated: z.boolean().optional().describe('Animate the edge stroke'),
|
|
48
|
+
},
|
|
49
|
+
buildInput: (input) => {
|
|
50
|
+
if (!input.from && !input.fromSearch) {
|
|
51
|
+
throw new OperationError('Provide either "from" or "fromSearch".');
|
|
52
|
+
}
|
|
53
|
+
if (!input.to && !input.toSearch) {
|
|
54
|
+
throw new OperationError('Provide either "to" or "toSearch".');
|
|
55
|
+
}
|
|
56
|
+
return input;
|
|
57
|
+
},
|
|
58
|
+
formatResult: (result) => {
|
|
59
|
+
const body = isRecord(result) ? result : {};
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: 'text' as const,
|
|
63
|
+
text: JSON.stringify({
|
|
64
|
+
id: body.id,
|
|
65
|
+
from: body.from,
|
|
66
|
+
to: body.to,
|
|
67
|
+
type: body.type,
|
|
68
|
+
label: body.label,
|
|
69
|
+
style: body.style,
|
|
70
|
+
animated: body.animated,
|
|
71
|
+
}, null, 2),
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
handler: (input) => {
|
|
77
|
+
const body: Record<string, unknown> = input;
|
|
78
|
+
const rawType = body.type;
|
|
79
|
+
const style = typeof body.style === 'string' ? body.style : undefined;
|
|
80
|
+
if (
|
|
81
|
+
!rawType ||
|
|
82
|
+
(!body.from && !body.fromSearch) ||
|
|
83
|
+
(!body.to && !body.toSearch)
|
|
84
|
+
) {
|
|
85
|
+
throw new OperationError('Missing required fields: type plus from/fromSearch and to/toSearch.');
|
|
86
|
+
}
|
|
87
|
+
if (typeof rawType !== 'string' || !VALID_EDGE_TYPES.has(rawType)) {
|
|
88
|
+
throw new OperationError(`Invalid edge type: "${String(rawType)}".`);
|
|
89
|
+
}
|
|
90
|
+
if (style && !VALID_EDGE_STYLES.has(style)) {
|
|
91
|
+
throw new OperationError(`Invalid edge style: "${style}". Use solid, dashed, or dotted.`);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return addCanvasEdge({
|
|
95
|
+
...(typeof body.from === 'string' ? { from: body.from } : {}),
|
|
96
|
+
...(typeof body.to === 'string' ? { to: body.to } : {}),
|
|
97
|
+
...(typeof body.fromSearch === 'string' ? { fromSearch: body.fromSearch } : {}),
|
|
98
|
+
...(typeof body.toSearch === 'string' ? { toSearch: body.toSearch } : {}),
|
|
99
|
+
type: rawType as CanvasEdge['type'],
|
|
100
|
+
...(body.label ? { label: String(body.label) } : {}),
|
|
101
|
+
...(style ? { style: style as CanvasEdge['style'] } : {}),
|
|
102
|
+
...(body.animated !== undefined ? { animated: Boolean(body.animated) } : {}),
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new OperationError(error instanceof Error ? error.message : 'Duplicate or self-edge.');
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
serialize: (edge) => ({ ok: true, ...edge }),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── edge.remove ───────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const edgeRemoveShape = {
|
|
114
|
+
id: z.string().optional().catch(undefined).describe('Edge ID to remove'),
|
|
115
|
+
edge_id: z.string().optional().catch(undefined).describe('Alias for id (legacy HTTP body field)'),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const edgeRemoveSchema = z.looseObject(edgeRemoveShape);
|
|
119
|
+
|
|
120
|
+
const edgeRemoveOperation = defineOperation<z.infer<typeof edgeRemoveSchema>, Record<string, unknown>>({
|
|
121
|
+
name: 'edge.remove',
|
|
122
|
+
mutates: true,
|
|
123
|
+
input: edgeRemoveSchema,
|
|
124
|
+
inputShape: edgeRemoveShape,
|
|
125
|
+
http: {
|
|
126
|
+
method: 'DELETE',
|
|
127
|
+
path: '/api/canvas/edge',
|
|
128
|
+
// Legacy clients send `{ edge_id }` in the DELETE body (RemoteCanvasAccess
|
|
129
|
+
// did); the invoker sends it as a query param — accept both.
|
|
130
|
+
readInput: async (req, params, url) => {
|
|
131
|
+
const query: Record<string, string> = {};
|
|
132
|
+
url.searchParams.forEach((value, key) => {
|
|
133
|
+
query[key] = value;
|
|
134
|
+
});
|
|
135
|
+
const body = await readJsonValue(req);
|
|
136
|
+
const record = isRecord(body) ? body : {};
|
|
137
|
+
return { ...query, ...record, ...params };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
mcp: {
|
|
141
|
+
toolName: 'canvas_remove_edge',
|
|
142
|
+
description: 'Remove an edge from the canvas.',
|
|
143
|
+
extraShape: {
|
|
144
|
+
id: z.string().describe('Edge ID to remove'),
|
|
145
|
+
},
|
|
146
|
+
formatResult: (result) => ({
|
|
147
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
handler: ({ edge_id, id }) => {
|
|
151
|
+
const edgeId = edge_id ?? id ?? '';
|
|
152
|
+
if (!edgeId) {
|
|
153
|
+
throw new OperationError('Missing edge_id.');
|
|
154
|
+
}
|
|
155
|
+
const { removed } = removeCanvasEdge(edgeId);
|
|
156
|
+
if (!removed) {
|
|
157
|
+
throw new OperationError(`Edge "${edgeId}" not found.`, 404);
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, removed: edgeId };
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const edgeOperations: Operation[] = [
|
|
164
|
+
edgeAddOperation,
|
|
165
|
+
edgeRemoveOperation,
|
|
166
|
+
];
|