pmx-canvas 0.9.3 → 0.10.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/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +9278 -2014
- package/CHANGELOG.md +22 -1
- package/dist/canvas/index.js +1 -1
- package/dist/canvas/mcp-app.html +1 -1
- package/dist/types/mcp/canvas-access.d.ts +1 -12
- package/dist/types/mcp/resource-subscriptions.d.ts +7 -0
- package/dist/types/mcp/server.d.ts +2 -0
- package/dist/types/server/ax-wait.d.ts +1 -0
- package/dist/types/server/canvas-operations.d.ts +1 -0
- package/dist/types/server/index.d.ts +3 -3
- package/dist/types/server/operations/http.d.ts +10 -0
- package/dist/types/server/operations/index.d.ts +2 -2
- package/dist/types/server/operations/invoker.d.ts +4 -3
- package/dist/types/server/operations/mcp-contracts.d.ts +20 -0
- package/dist/types/server/operations/mcp.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +1 -0
- package/dist/types/server/operations/registry.d.ts +2 -2
- package/dist/types/server/operations/types.d.ts +12 -2
- package/dist/types/server/server.d.ts +2 -0
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +1 -1
- package/docs/http-api.md +31 -0
- package/docs/mcp.md +93 -21
- package/docs/plans/plan-019-post-0.9-roadmap.md +123 -6
- package/docs/sdk.md +5 -2
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +9278 -2014
- package/skills/pmx-canvas/SKILL.md +9 -4
- package/skills/pmx-canvas/references/full-reference.md +35 -2
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/mcp/canvas-access.ts +6 -47
- package/src/mcp/resource-subscriptions.ts +181 -0
- package/src/mcp/server.ts +185 -219
- package/src/server/ax-wait.ts +21 -6
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/index.ts +12 -4
- package/src/server/operations/http.ts +69 -21
- package/src/server/operations/index.ts +8 -1
- package/src/server/operations/invoker.ts +53 -5
- package/src/server/operations/mcp-contracts.ts +339 -0
- package/src/server/operations/mcp.ts +153 -18
- package/src/server/operations/ops/ax-await.ts +18 -10
- package/src/server/operations/ops/ax-read.ts +2 -0
- package/src/server/operations/ops/ax-timeline.ts +2 -2
- package/src/server/operations/ops/batch.ts +27 -8
- package/src/server/operations/ops/nodes.ts +18 -3
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +10 -0
- package/src/server/webpage-node.ts +9 -4
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { z, type ZodRawShape, type ZodTypeAny } from 'zod';
|
|
11
11
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
|
13
|
+
import type { ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/types.js';
|
|
12
14
|
import { getOperation, listOperations } from './registry.js';
|
|
13
15
|
import { agentSourceLabel, type OperationInvoker } from './invoker.js';
|
|
14
|
-
import { OperationError, type Operation, type OperationMcpToolHost } from './types.js';
|
|
16
|
+
import { OperationError, type Operation, type OperationMcpToolHost, type OperationExecutionOptions } from './types.js';
|
|
17
|
+
import {
|
|
18
|
+
operationOutputSchema,
|
|
19
|
+
operationToolAnnotations,
|
|
20
|
+
operationToolError,
|
|
21
|
+
structuredOperationResult,
|
|
22
|
+
} from './mcp-contracts.js';
|
|
15
23
|
import {
|
|
16
24
|
CONTEXT_READ_OPS,
|
|
17
25
|
contextReadFromPayload,
|
|
@@ -28,23 +36,51 @@ export interface OperationToolHost extends OperationMcpToolHost {
|
|
|
28
36
|
recordContextRead(read: ContextReadInput): Promise<void>;
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
function executionOptions(extra: RequestHandlerExtra<ServerRequest, ServerNotification>): OperationExecutionOptions {
|
|
40
|
+
const progressToken = extra._meta?.progressToken;
|
|
41
|
+
return {
|
|
42
|
+
signal: extra.signal,
|
|
43
|
+
...(progressToken !== undefined
|
|
44
|
+
? {
|
|
45
|
+
onProgress: (progress: Parameters<NonNullable<OperationExecutionOptions['onProgress']>>[0]) =>
|
|
46
|
+
extra.sendNotification({ method: 'notifications/progress', params: { ...progress, progressToken } }),
|
|
47
|
+
}
|
|
48
|
+
: {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
async function invokeMcpOperation(
|
|
32
53
|
host: OperationToolHost,
|
|
33
54
|
op: Operation,
|
|
34
55
|
opInput: Record<string, unknown>,
|
|
35
56
|
formatInput: Record<string, unknown>,
|
|
36
57
|
consumer: string | null,
|
|
58
|
+
options: OperationExecutionOptions,
|
|
37
59
|
) {
|
|
60
|
+
options.signal?.throwIfAborted();
|
|
38
61
|
const board = typeof opInput.board === 'string' && opInput.board.trim() ? opInput.board.trim() : undefined;
|
|
39
62
|
const readTarget = CONTEXT_READ_OPS.has(op.name) ? await host.prepareContextRead(board) : null;
|
|
40
63
|
if (readTarget?.boardId && 'board' in op.inputShape) {
|
|
41
64
|
opInput = { ...opInput, board: readTarget.boardId };
|
|
42
65
|
formatInput = { ...formatInput, board: readTarget.boardId };
|
|
43
66
|
}
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
67
|
+
options.signal?.throwIfAborted();
|
|
68
|
+
const invoker = host.invoker();
|
|
69
|
+
const result = await invoker.invoke(op.name, opInput, options);
|
|
70
|
+
// Preserve the host receiver, and bind options to formatter follow-up reads
|
|
71
|
+
// (history flags, snapshot summary, streaming node metadata).
|
|
72
|
+
const formatterHost: OperationMcpToolHost = {
|
|
73
|
+
getPinnedNodeIds: (target) => host.getPinnedNodeIds(target),
|
|
74
|
+
invoker: () => ({ invoke: (name, input, followUp) => invoker.invoke(name, input, { ...options, ...followUp }) }),
|
|
75
|
+
};
|
|
76
|
+
const formatted = structuredOperationResult(
|
|
77
|
+
op.name,
|
|
78
|
+
op.mcp?.formatResult
|
|
79
|
+
? await op.mcp.formatResult(result, formatInput, formatterHost)
|
|
80
|
+
: { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] },
|
|
81
|
+
);
|
|
82
|
+
// Do not throwIfAborted here: batch may already have returned a partial
|
|
83
|
+
// outcome, and cancellation must never erase completed work from the reply.
|
|
48
84
|
if (readTarget) {
|
|
49
85
|
const text = formatted.content
|
|
50
86
|
.filter((item): item is Extract<(typeof formatted.content)[number], { type: 'text' }> => item.type === 'text')
|
|
@@ -116,17 +152,24 @@ export function registerOperationTools(server: McpServer, getHost: () => Promise
|
|
|
116
152
|
// finding A). Loose parsing forwards unknown keys to the op layer,
|
|
117
153
|
// the single authority on dead-field rejection, HTTP and MCP alike.
|
|
118
154
|
inputSchema: z.looseObject({ ...op.inputShape, ...(tool.extraShape ?? {}) }),
|
|
155
|
+
outputSchema: operationOutputSchema([op.name]),
|
|
156
|
+
annotations: operationToolAnnotations(tool.toolName),
|
|
119
157
|
},
|
|
120
|
-
async (input: Record<string, unknown
|
|
158
|
+
async (input: Record<string, unknown>, extra) => {
|
|
121
159
|
try {
|
|
122
160
|
const host = await getHost();
|
|
161
|
+
extra.signal.throwIfAborted();
|
|
123
162
|
const opInput = tool.buildInput ? tool.buildInput(input) : input;
|
|
124
|
-
return await invokeMcpOperation(
|
|
163
|
+
return await invokeMcpOperation(
|
|
164
|
+
host,
|
|
165
|
+
op,
|
|
166
|
+
opInput,
|
|
167
|
+
input,
|
|
168
|
+
server.server.getClientVersion()?.name ?? null,
|
|
169
|
+
executionOptions(extra),
|
|
170
|
+
);
|
|
125
171
|
} catch (error) {
|
|
126
|
-
return
|
|
127
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
128
|
-
isError: true,
|
|
129
|
-
};
|
|
172
|
+
return operationToolError(error);
|
|
130
173
|
}
|
|
131
174
|
},
|
|
132
175
|
);
|
|
@@ -295,6 +338,88 @@ function requiredMcpFields(op: Operation): string[] {
|
|
|
295
338
|
return keys;
|
|
296
339
|
}
|
|
297
340
|
|
|
341
|
+
/** Registry metadata only: never boots a canvas or records a content read. */
|
|
342
|
+
export function registerOperationHelpTool(server: McpServer): void {
|
|
343
|
+
const folded = compositeFoldedOpNames();
|
|
344
|
+
server.registerTool(
|
|
345
|
+
'canvas_help',
|
|
346
|
+
{
|
|
347
|
+
description:
|
|
348
|
+
'Look up an MCP operation by registry name (e.g. node.add, ax.approval.request). Returns its public tool, argument schema and guidance. Omit op to list operation names. Does not execute operations or read canvas content.',
|
|
349
|
+
inputSchema: z.object({ op: z.string().min(1).optional() }),
|
|
350
|
+
outputSchema: z.object({
|
|
351
|
+
result: z.union([
|
|
352
|
+
z.strictObject({ operations: z.array(z.string()) }),
|
|
353
|
+
z.strictObject({
|
|
354
|
+
operation: z.string(),
|
|
355
|
+
tool: z.string(),
|
|
356
|
+
description: z.string(),
|
|
357
|
+
inputSchema: z.record(z.string(), z.unknown()),
|
|
358
|
+
}),
|
|
359
|
+
]),
|
|
360
|
+
}),
|
|
361
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
362
|
+
},
|
|
363
|
+
({ op: name }) => {
|
|
364
|
+
const operations = listOperations().filter((op) => op.mcp || folded.has(op.name));
|
|
365
|
+
if (name === undefined) {
|
|
366
|
+
const result = { operations: operations.map((op) => op.name).sort() };
|
|
367
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
|
|
368
|
+
}
|
|
369
|
+
const op = operations.find((op) => op.name === name);
|
|
370
|
+
if (!op) {
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: 'text', text: `Unknown MCP operation "${name}". Omit op to list available operations.` }],
|
|
373
|
+
isError: true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const composite = compositeToolDefinitions.find((def) => compositeMemberOps(def).includes(name));
|
|
377
|
+
const publicName = (field: string) =>
|
|
378
|
+
Object.entries(composite?.fieldRemap ?? {}).find(([, original]) => original === field)?.[0] ?? field;
|
|
379
|
+
const shape = Object.fromEntries(
|
|
380
|
+
Object.entries({ ...op.inputShape, ...op.mcp?.extraShape }).map(([field, schema]) => [
|
|
381
|
+
publicName(field),
|
|
382
|
+
schema,
|
|
383
|
+
]),
|
|
384
|
+
);
|
|
385
|
+
if (composite?.resolveOp) {
|
|
386
|
+
// Resolve the same kind/action matrix used by composite dispatch.
|
|
387
|
+
const kinds = composite.extraDiscriminatorShape?.kind;
|
|
388
|
+
if (!(kinds instanceof z.ZodEnum)) throw new Error(`Missing kind enum for ${composite.toolName}.`);
|
|
389
|
+
for (const kind of kinds.options) {
|
|
390
|
+
for (const action of composite.actionEnum ?? []) {
|
|
391
|
+
if (composite.resolveOp({ kind: String(kind), action }) === name) {
|
|
392
|
+
shape.kind = z.literal(kind);
|
|
393
|
+
shape.action = z.literal(action);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} else if (composite) {
|
|
398
|
+
const action = Object.entries(composite.actions).find(([, operation]) => operation === name)![0];
|
|
399
|
+
shape.action = z.literal(action);
|
|
400
|
+
}
|
|
401
|
+
const inputSchema = z.toJSONSchema(z.looseObject(shape), { target: 'draft-7', io: 'input' });
|
|
402
|
+
// Mirror dispatch's required-name alternatives rather than demanding both names.
|
|
403
|
+
for (const [field, aliases] of Object.entries(REQUIRED_FIELD_ALIASES[name] ?? {})) {
|
|
404
|
+
const key = publicName(field);
|
|
405
|
+
if (!inputSchema.required?.includes(key)) continue;
|
|
406
|
+
inputSchema.required = inputSchema.required.filter((required) => required !== key);
|
|
407
|
+
inputSchema.allOf = [
|
|
408
|
+
...(inputSchema.allOf ?? []),
|
|
409
|
+
{ anyOf: [key, ...aliases.map(publicName)].map((alternative) => ({ required: [alternative] })) },
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
const result = {
|
|
413
|
+
operation: name,
|
|
414
|
+
tool: composite?.toolName ?? op.mcp!.toolName,
|
|
415
|
+
description: op.mcp?.description ?? composite!.description,
|
|
416
|
+
inputSchema,
|
|
417
|
+
};
|
|
418
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: { result } };
|
|
419
|
+
},
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
298
423
|
/**
|
|
299
424
|
* Register composite (action-discriminated) MCP tools (plan-006). Each action
|
|
300
425
|
* dispatches to a registered operation, reusing that op's `mcp.buildInput` and
|
|
@@ -317,10 +442,16 @@ export function registerCompositeTools(
|
|
|
317
442
|
// must reach the op layer's dead-field rejection instead of being stripped.
|
|
318
443
|
server.registerTool(
|
|
319
444
|
def.toolName,
|
|
320
|
-
{
|
|
321
|
-
|
|
445
|
+
{
|
|
446
|
+
description: def.description,
|
|
447
|
+
inputSchema: z.looseObject(buildCompositeShape(def)),
|
|
448
|
+
outputSchema: operationOutputSchema(compositeMemberOps(def)),
|
|
449
|
+
annotations: operationToolAnnotations(def.toolName),
|
|
450
|
+
},
|
|
451
|
+
async (input: Record<string, unknown>, extra) => {
|
|
322
452
|
try {
|
|
323
453
|
const host = await getHost();
|
|
454
|
+
extra.signal.throwIfAborted();
|
|
324
455
|
const opName = resolveCompositeOp(def, input);
|
|
325
456
|
const op = getOperation(opName);
|
|
326
457
|
// Strip the composite discriminators (action + any extra, e.g. `kind`)
|
|
@@ -335,12 +466,16 @@ export function registerCompositeTools(
|
|
|
335
466
|
);
|
|
336
467
|
}
|
|
337
468
|
const opInput = op.mcp?.buildInput ? op.mcp.buildInput(rest) : rest;
|
|
338
|
-
return await invokeMcpOperation(
|
|
469
|
+
return await invokeMcpOperation(
|
|
470
|
+
host,
|
|
471
|
+
op,
|
|
472
|
+
opInput,
|
|
473
|
+
rest,
|
|
474
|
+
server.server.getClientVersion()?.name ?? null,
|
|
475
|
+
executionOptions(extra),
|
|
476
|
+
);
|
|
339
477
|
} catch (error) {
|
|
340
|
-
return
|
|
341
|
-
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
|
|
342
|
-
isError: true,
|
|
343
|
-
};
|
|
478
|
+
return operationToolError(error);
|
|
344
479
|
}
|
|
345
480
|
},
|
|
346
481
|
);
|
|
@@ -39,11 +39,7 @@
|
|
|
39
39
|
* the legacy error body — server-api only reads `missing.status`.
|
|
40
40
|
*
|
|
41
41
|
* Documented behavior change (accepted long-poll tradeoff, plan-007 sub-slice):
|
|
42
|
-
* -
|
|
43
|
-
* client disconnect aborted the wait early. The registry handler has no access
|
|
44
|
-
* to the HTTP Request, so the wait now runs to its (≤120s, subscription-based,
|
|
45
|
-
* cheap) timeout instead of aborting on disconnect. Resolution detection,
|
|
46
|
-
* timeout, and the `{ value, pending }` result are otherwise identical.
|
|
42
|
+
* - Request cancellation rejects the wait and disposes its subscription/timer.
|
|
47
43
|
* - Remote-MCP only (daemon mode, untested for these reads): the legacy
|
|
48
44
|
* RemoteCanvasAccess.await* methods special-cased a 404 → `{ <key>:null }`. The
|
|
49
45
|
* generic HttpOperationInvoker throws on 404, so an await-on-missing over a
|
|
@@ -70,9 +66,12 @@ function clampTimeoutMs(value: unknown): number {
|
|
|
70
66
|
return Math.min(ms, AX_WAIT_MAX_MS);
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
/**
|
|
69
|
+
/** Accept the operation's timeoutMs as well as the direct HTTP waitMs spelling. */
|
|
74
70
|
function readWaitMsInput(_req: Request, params: Record<string, string>, url: URL): Record<string, unknown> {
|
|
75
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
...params,
|
|
73
|
+
timeoutMs: clampTimeoutMs(url.searchParams.get('timeoutMs') ?? url.searchParams.get('waitMs') ?? ''),
|
|
74
|
+
};
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
/** MCP buildInput: forward id + timeoutMs (omitted ⇒ 30000), clamped to [0, max]. */
|
|
@@ -131,13 +130,16 @@ const axApprovalGetOperation = defineOperation<z.infer<typeof awaitInputSchema>,
|
|
|
131
130
|
};
|
|
132
131
|
},
|
|
133
132
|
},
|
|
134
|
-
handler: async (input) => {
|
|
133
|
+
handler: async (input, ctx) => {
|
|
135
134
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
135
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for approval' });
|
|
136
136
|
const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
|
|
137
137
|
read: () => canvasState.getApproval(id),
|
|
138
138
|
isResolved: (g) => g.status !== 'pending',
|
|
139
139
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
140
|
+
signal: ctx.signal,
|
|
140
141
|
});
|
|
142
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
141
143
|
return { ok: value !== null, approvalGate: value, pending } as unknown as Record<string, unknown>;
|
|
142
144
|
},
|
|
143
145
|
});
|
|
@@ -170,13 +172,16 @@ const axElicitationGetOperation = defineOperation<z.infer<typeof awaitInputSchem
|
|
|
170
172
|
};
|
|
171
173
|
},
|
|
172
174
|
},
|
|
173
|
-
handler: async (input) => {
|
|
175
|
+
handler: async (input, ctx) => {
|
|
174
176
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
177
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for elicitation' });
|
|
175
178
|
const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
|
|
176
179
|
read: () => canvasState.getElicitation(id),
|
|
177
180
|
isResolved: (e) => e.status !== 'pending',
|
|
178
181
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
182
|
+
signal: ctx.signal,
|
|
179
183
|
});
|
|
184
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
180
185
|
return { ok: value !== null, elicitation: value, pending } as unknown as Record<string, unknown>;
|
|
181
186
|
},
|
|
182
187
|
});
|
|
@@ -209,13 +214,16 @@ const axModeGetOperation = defineOperation<z.infer<typeof awaitInputSchema>, Rec
|
|
|
209
214
|
};
|
|
210
215
|
},
|
|
211
216
|
},
|
|
212
|
-
handler: async (input) => {
|
|
217
|
+
handler: async (input, ctx) => {
|
|
213
218
|
const id = typeof input.id === 'string' ? input.id : '';
|
|
219
|
+
await ctx.onProgress?.({ progress: 0, total: 1, message: 'Waiting for mode resolution' });
|
|
214
220
|
const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
|
|
215
221
|
read: () => canvasState.getModeRequest(id),
|
|
216
222
|
isResolved: (m) => m.status !== 'pending',
|
|
217
223
|
timeoutMs: clampTimeoutMs(input.timeoutMs),
|
|
224
|
+
signal: ctx.signal,
|
|
218
225
|
});
|
|
226
|
+
await ctx.onProgress?.({ progress: 1, total: 1, message: pending ? 'Wait timed out' : 'Wait complete' });
|
|
219
227
|
return { ok: value !== null, modeRequest: value, pending } as unknown as Record<string, unknown>;
|
|
220
228
|
},
|
|
221
229
|
});
|
|
@@ -335,6 +335,8 @@ const interactionOperation = defineOperation<z.infer<typeof interactionSchema>,
|
|
|
335
335
|
http: {
|
|
336
336
|
method: 'POST',
|
|
337
337
|
path: '/api/canvas/ax/interaction',
|
|
338
|
+
// MCP preserves the structured rejection code as well as its message.
|
|
339
|
+
errorBodyAsResult: true,
|
|
338
340
|
// Legacy wire: 200 when accepted, the envelope's own status when refused.
|
|
339
341
|
status: (result) =>
|
|
340
342
|
isRecord(result) && result.ok !== true && typeof result.status === 'number' ? result.status : 200,
|
|
@@ -341,7 +341,7 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
341
341
|
// `consumer` is a loop-safety scope, not a source label — never defaulted.
|
|
342
342
|
formatResult: axJsonResult,
|
|
343
343
|
},
|
|
344
|
-
handler: async (input) => {
|
|
344
|
+
handler: async (input, ctx) => {
|
|
345
345
|
const consumer = typeof input.consumer === 'string' ? input.consumer : undefined;
|
|
346
346
|
// A claim is proof this consumer polls steering — its presence (session or
|
|
347
347
|
// external writer) becomes a legitimate composer steer target.
|
|
@@ -363,7 +363,7 @@ const axDeliveryPendingOperation = defineOperation<z.infer<typeof axDeliveryPend
|
|
|
363
363
|
// Long-poll: hosts that cannot be woken from outside (Copilot, Codex —
|
|
364
364
|
// their model runs only while the host gives it a turn) park here and
|
|
365
365
|
// wake the moment steering lands, instead of burning the turn polling.
|
|
366
|
-
await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs });
|
|
366
|
+
await waitForAxCondition({ ready: () => readPending().length > 0, timeoutMs, signal: ctx.signal });
|
|
367
367
|
pending = readPending();
|
|
368
368
|
}
|
|
369
369
|
// The MCP tool aggregated pendingActivity; one wire body now serves it over
|
|
@@ -44,6 +44,7 @@ export interface BatchEnvelope {
|
|
|
44
44
|
refs: Record<string, unknown>;
|
|
45
45
|
failedIndex?: number;
|
|
46
46
|
error?: string;
|
|
47
|
+
cancelled?: boolean;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const SUPPORTED_BATCH_OPS = new Set([
|
|
@@ -220,7 +221,7 @@ function shapeBatchEntry(op: string, result: unknown, creationReceipt = false):
|
|
|
220
221
|
return body;
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
224
|
+
async function runBatch(operations: BatchEntry[], ctx: OperationContext): Promise<BatchEnvelope> {
|
|
224
225
|
const refs: Record<string, unknown> = {};
|
|
225
226
|
const results: Array<Record<string, unknown>> = [];
|
|
226
227
|
|
|
@@ -231,6 +232,15 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
|
231
232
|
return { ok: false, failedIndex: index, error: `Operation ${index} has invalid args.`, results, refs };
|
|
232
233
|
}
|
|
233
234
|
try {
|
|
235
|
+
ctx.signal?.throwIfAborted();
|
|
236
|
+
if (ctx.onProgress) {
|
|
237
|
+
const boardGeneration = canvasState.boardGeneration;
|
|
238
|
+
await ctx.onProgress({ progress: index, total: operations.length, message: operation.op });
|
|
239
|
+
if (canvasState.boardGeneration !== boardGeneration) {
|
|
240
|
+
throw new Error('The board changed while the batch was running.');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
ctx.signal?.throwIfAborted();
|
|
234
244
|
if (!SUPPORTED_BATCH_OPS.has(operation.op)) {
|
|
235
245
|
return {
|
|
236
246
|
ok: false,
|
|
@@ -259,35 +269,43 @@ async function runBatch(operations: BatchEntry[]): Promise<BatchEnvelope> {
|
|
|
259
269
|
// ghost-cursor exemption for batch operations). No `fromWorkbench`: the
|
|
260
270
|
// browser never issues batches, so inner writes are agent writes and the
|
|
261
271
|
// scope fence applies to each of them.
|
|
262
|
-
const pending = executeOperation(dispatch.name, dispatch.args, {
|
|
272
|
+
const pending = executeOperation(dispatch.name, dispatch.args, {
|
|
273
|
+
suppressAutoGhost: true,
|
|
274
|
+
signal: ctx.signal,
|
|
275
|
+
onProgress: ctx.onProgress
|
|
276
|
+
? (progress) => ctx.onProgress!({ progress: index, total: operations.length, message: progress.message })
|
|
277
|
+
: undefined,
|
|
278
|
+
});
|
|
263
279
|
// The first write may synchronously open a board from Home. Capture after
|
|
264
280
|
// dispatch, then refuse to continue a partially completed batch elsewhere.
|
|
265
281
|
const boardGeneration = canvasState.boardGeneration;
|
|
266
282
|
const raw = await pending;
|
|
267
|
-
|
|
268
|
-
throw new Error('The board changed while the batch was running.');
|
|
269
|
-
}
|
|
283
|
+
const boardChanged = canvasState.boardGeneration !== boardGeneration;
|
|
270
284
|
const result = shapeBatchEntry(
|
|
271
285
|
operation.op,
|
|
272
286
|
raw,
|
|
273
|
-
|
|
274
|
-
(
|
|
287
|
+
boardChanged ||
|
|
288
|
+
(['node.add', 'group.create', 'edge.add'].includes(operation.op) &&
|
|
289
|
+
(args.requestId !== undefined || args.destinationBoardId !== undefined)),
|
|
275
290
|
);
|
|
276
291
|
results.push(result);
|
|
277
292
|
if (typeof operation.assign === 'string' && operation.assign.trim().length > 0) {
|
|
278
293
|
refs[operation.assign] = result;
|
|
279
294
|
}
|
|
295
|
+
if (boardChanged) throw new Error('The board changed while the batch was running.');
|
|
280
296
|
} catch (error) {
|
|
281
297
|
return {
|
|
282
298
|
ok: false,
|
|
283
299
|
failedIndex: index,
|
|
284
300
|
error: error instanceof Error ? error.message : String(error),
|
|
301
|
+
...(ctx.signal?.aborted ? { cancelled: true } : {}),
|
|
285
302
|
results,
|
|
286
303
|
refs,
|
|
287
304
|
};
|
|
288
305
|
}
|
|
289
306
|
}
|
|
290
307
|
|
|
308
|
+
await ctx.onProgress?.({ progress: operations.length, total: operations.length, message: 'Batch complete' });
|
|
291
309
|
return { ok: true, results, refs };
|
|
292
310
|
}
|
|
293
311
|
|
|
@@ -356,6 +374,7 @@ function compactBatchResult(result: BatchEnvelope): Record<string, unknown> {
|
|
|
356
374
|
ok: result.ok,
|
|
357
375
|
...(result.failedIndex !== undefined ? { failedIndex: result.failedIndex } : {}),
|
|
358
376
|
...(result.error ? { error: result.error } : {}),
|
|
377
|
+
...(result.cancelled ? { cancelled: true } : {}),
|
|
359
378
|
results: result.results.map((entry) => compactBatchValue(entry)),
|
|
360
379
|
refs: Object.fromEntries(Object.entries(result.refs).map(([key, value]) => [key, compactBatchValue(value)])),
|
|
361
380
|
};
|
|
@@ -406,7 +425,7 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
|
|
|
406
425
|
// Suppress every per-entry SSE frame (auto layout emit + each handler's
|
|
407
426
|
// ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
|
|
408
427
|
const revision = canvasState.getContentRevision().revision;
|
|
409
|
-
const envelope = await runWithSuppressedEmits(() => runBatch(operations));
|
|
428
|
+
const envelope = await runWithSuppressedEmits(() => runBatch(operations, ctx));
|
|
410
429
|
if (
|
|
411
430
|
revision !== canvasState.getContentRevision().revision ||
|
|
412
431
|
operations.some(
|
|
@@ -633,6 +633,7 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
|
|
|
633
633
|
|
|
634
634
|
const extraData = isRecord(body.data) ? body.data : undefined;
|
|
635
635
|
const geometry = resolveCreateGeometry(body);
|
|
636
|
+
ctx.signal?.throwIfAborted();
|
|
636
637
|
const { id, node, sizeAdjustment } = addCanvasNode({
|
|
637
638
|
type: 'webpage',
|
|
638
639
|
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
@@ -648,7 +649,21 @@ async function createWebpageNode(body: Record<string, unknown>, ctx: OperationCo
|
|
|
648
649
|
// registry emits the final layout update after the handler returns.
|
|
649
650
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
650
651
|
const boardGeneration = canvasState.boardGeneration;
|
|
651
|
-
|
|
652
|
+
let refreshed: Awaited<ReturnType<typeof refreshCanvasWebpageNode>>;
|
|
653
|
+
try {
|
|
654
|
+
refreshed = await refreshCanvasWebpageNode(id, { signal: ctx.signal });
|
|
655
|
+
} catch (error) {
|
|
656
|
+
if (!ctx.signal?.aborted) throw error;
|
|
657
|
+
// Creation already committed. Return its receipt without writing an error
|
|
658
|
+
// status; batches retain this node and stop before their next entry.
|
|
659
|
+
return {
|
|
660
|
+
node,
|
|
661
|
+
extras: {
|
|
662
|
+
fetch: { ok: false, cancelled: true, error: 'Webpage fetch cancelled.' },
|
|
663
|
+
...(sizeAdjustment ? { sizeAdjustment } : {}),
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
}
|
|
652
667
|
if (canvasState.boardGeneration !== boardGeneration) {
|
|
653
668
|
throw new OperationError('The board changed while the webpage was loading.', 409);
|
|
654
669
|
}
|
|
@@ -1186,7 +1201,7 @@ const nodeUpdateOperation = defineOperation<z.infer<typeof nodeUpdateSchema>, Re
|
|
|
1186
1201
|
throw new OperationError(error instanceof Error ? error.message : 'Invalid webpage URL.');
|
|
1187
1202
|
}
|
|
1188
1203
|
}
|
|
1189
|
-
const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}) });
|
|
1204
|
+
const result = await refreshCanvasWebpageNode(id, { ...(url ? { url } : {}), signal: ctx.signal });
|
|
1190
1205
|
return result as unknown as Record<string, unknown>;
|
|
1191
1206
|
}
|
|
1192
1207
|
|
|
@@ -1442,7 +1457,7 @@ const nodeRefreshOperation = defineOperation<z.infer<typeof nodeRefreshSchema>,
|
|
|
1442
1457
|
}
|
|
1443
1458
|
// Legacy wire: the layout update fires after every completed refresh
|
|
1444
1459
|
// attempt, success or failure (the node may have partial refresh state).
|
|
1445
|
-
const result = await refreshCanvasWebpageNode(nodeId, { ...(url ? { url } : {}) });
|
|
1460
|
+
const result = await refreshCanvasWebpageNode(nodeId, { ...(url ? { url } : {}), signal: ctx.signal });
|
|
1446
1461
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1447
1462
|
return result as unknown as Record<string, unknown>;
|
|
1448
1463
|
},
|
|
@@ -20,7 +20,7 @@ import type { MemoryGraph } from '../../shared/memory-graph.js';
|
|
|
20
20
|
import { openBoardForWrite } from '../canvas-operations.js';
|
|
21
21
|
import { boardsPayload } from './ops/boards.js';
|
|
22
22
|
import type { PmxAxIntent, PmxAxIntentKind } from '../../shared/ax-intent.js';
|
|
23
|
-
import { OperationError, type Operation, type OperationContext } from './types.js';
|
|
23
|
+
import { OperationError, type Operation, type OperationContext, type OperationExecutionOptions } from './types.js';
|
|
24
24
|
|
|
25
25
|
const operations = new Map<string, Operation>();
|
|
26
26
|
|
|
@@ -75,8 +75,6 @@ export async function runWithSuppressedEmits<T>(fn: () => Promise<T>): Promise<T
|
|
|
75
75
|
return suppressedEmits.run(true, fn);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const operationContext: OperationContext = { emit: emitOperationEvent };
|
|
79
|
-
|
|
80
78
|
const INTENT_KINDS_BY_OPERATION: Record<string, readonly PmxAxIntentKind[]> = {
|
|
81
79
|
'node.add': ['create'],
|
|
82
80
|
'jsonrender.add': ['create'],
|
|
@@ -204,7 +202,7 @@ function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<s
|
|
|
204
202
|
}
|
|
205
203
|
}
|
|
206
204
|
|
|
207
|
-
export interface ExecuteOperationMeta {
|
|
205
|
+
export interface ExecuteOperationMeta extends OperationExecutionOptions {
|
|
208
206
|
/**
|
|
209
207
|
* Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
|
|
210
208
|
* (a human dragging a node is not agent activity) and by canvas.batch for
|
|
@@ -395,6 +393,8 @@ export async function executeOperation(
|
|
|
395
393
|
rawInput: unknown,
|
|
396
394
|
meta: ExecuteOperationMeta = {},
|
|
397
395
|
): Promise<unknown> {
|
|
396
|
+
// Check before attribution, board auto-open, ghosts, or any handler writes.
|
|
397
|
+
meta.signal?.throwIfAborted();
|
|
398
398
|
// Attribution follows this call chain across awaits and nested operations.
|
|
399
399
|
const inputAgentId = asRecord(rawInput).agentId;
|
|
400
400
|
return withCurrentActor(
|
|
@@ -479,6 +479,22 @@ function needsOpenBoard(op: Operation): boolean {
|
|
|
479
479
|
}
|
|
480
480
|
|
|
481
481
|
async function executeOperationInner(name: string, rawInput: unknown, meta: ExecuteOperationMeta): Promise<unknown> {
|
|
482
|
+
meta.signal?.throwIfAborted();
|
|
483
|
+
const operationContext: OperationContext = {
|
|
484
|
+
emit: emitOperationEvent,
|
|
485
|
+
signal: meta.signal,
|
|
486
|
+
// Progress is observational. A disconnected observer must not erase the
|
|
487
|
+
// receipt of a completed write or strand a batch's final layout emit.
|
|
488
|
+
onProgress: meta.onProgress
|
|
489
|
+
? async (progress) => {
|
|
490
|
+
try {
|
|
491
|
+
await meta.onProgress!(progress);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (!meta.signal?.aborted) console.error(`[operation] progress callback failed for ${name}:`, error);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
: undefined,
|
|
497
|
+
};
|
|
482
498
|
const op = getOperation(name);
|
|
483
499
|
const storedCreation =
|
|
484
500
|
['node.add', 'group.create', 'edge.add'].includes(name) &&
|
|
@@ -543,6 +559,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
|
|
|
543
559
|
}
|
|
544
560
|
if (storedCreation) {
|
|
545
561
|
const revision = canvasState.getContentRevision().revision;
|
|
562
|
+
meta.signal?.throwIfAborted();
|
|
546
563
|
const result = await op.execute(rawInput, operationContext);
|
|
547
564
|
const destination = asRecord(rawInput).destinationBoardId ?? canvasState.activeBoardId;
|
|
548
565
|
if (destination === canvasState.activeBoardId || revision !== canvasState.getContentRevision().revision)
|
|
@@ -566,6 +583,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
|
|
|
566
583
|
intentId,
|
|
567
584
|
allowedKinds!,
|
|
568
585
|
async () => {
|
|
586
|
+
meta.signal?.throwIfAborted();
|
|
569
587
|
const result = await op.execute(input, operationContext);
|
|
570
588
|
if (op.mutates) {
|
|
571
589
|
emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
@@ -606,6 +624,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
|
|
|
606
624
|
if (ghost) {
|
|
607
625
|
const ghostId = ghost.id;
|
|
608
626
|
try {
|
|
627
|
+
meta.signal?.throwIfAborted();
|
|
609
628
|
const result = await op.execute(rawInput, operationContext);
|
|
610
629
|
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
611
630
|
intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
|
|
@@ -621,6 +640,7 @@ async function executeOperationInner(name: string, rawInput: unknown, meta: Exec
|
|
|
621
640
|
const beforeBoard = canvasState.activeBoardId;
|
|
622
641
|
const beforeRevision =
|
|
623
642
|
name === 'board.create-from' || name === 'board.delete' ? canvasState.getContentRevision().revision : null;
|
|
643
|
+
meta.signal?.throwIfAborted();
|
|
624
644
|
const result = await op.execute(rawInput, operationContext);
|
|
625
645
|
// Library writes can invalidate relations on the open board without being
|
|
626
646
|
// layout mutations themselves. Publish those clocks once, after the write.
|
|
@@ -29,7 +29,19 @@ export class OperationError extends Error {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export interface
|
|
32
|
+
export interface OperationProgress {
|
|
33
|
+
progress: number;
|
|
34
|
+
total?: number;
|
|
35
|
+
message?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cooperative cancellation: completed writes are never rolled back. */
|
|
39
|
+
export interface OperationExecutionOptions {
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
onProgress?: (progress: OperationProgress) => void | Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OperationContext extends OperationExecutionOptions {
|
|
33
45
|
/** Emit a workbench SSE event (e.g. extra `canvas-layout-update` frames, focus, viewport). */
|
|
34
46
|
emit(event: string, payload?: Record<string, unknown>): void;
|
|
35
47
|
}
|
|
@@ -67,7 +79,9 @@ export interface OperationMcpToolHost {
|
|
|
67
79
|
* Used by formatters that need a follow-up read (undo/redo history flags,
|
|
68
80
|
* restore summary).
|
|
69
81
|
*/
|
|
70
|
-
invoker(): {
|
|
82
|
+
invoker(): {
|
|
83
|
+
invoke(name: string, input: Record<string, unknown>, options?: OperationExecutionOptions): Promise<unknown>;
|
|
84
|
+
};
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
export interface OperationMcpTool {
|
package/src/server/server.ts
CHANGED
|
@@ -930,6 +930,15 @@ let workbenchEventSeq = 0;
|
|
|
930
930
|
const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
|
|
931
931
|
const workbenchPollWaiters = new Set<() => void>();
|
|
932
932
|
let remoteEdgesSignature = '';
|
|
933
|
+
const workbenchEventListeners = new Set<(event: string) => void>();
|
|
934
|
+
|
|
935
|
+
/** In-process invalidation observers share the same events as daemon SSE clients. */
|
|
936
|
+
export function onWorkbenchEvent(listener: (event: string) => void): () => void {
|
|
937
|
+
workbenchEventListeners.add(listener);
|
|
938
|
+
return () => {
|
|
939
|
+
workbenchEventListeners.delete(listener);
|
|
940
|
+
};
|
|
941
|
+
}
|
|
933
942
|
|
|
934
943
|
function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
935
944
|
let remoteEdgesChanged = false;
|
|
@@ -975,6 +984,7 @@ function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPa
|
|
|
975
984
|
syncCanvasBrowserOpenedFromSubscribers();
|
|
976
985
|
}
|
|
977
986
|
}
|
|
987
|
+
for (const listener of workbenchEventListeners) listener(event);
|
|
978
988
|
if (remoteEdgesChanged) emitPrimaryWorkbenchEvent('boards-changed', boardsPayload());
|
|
979
989
|
}
|
|
980
990
|
|