pmx-canvas 0.9.3 → 0.11.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 +9418 -2016
- package/CHANGELOG.md +54 -1
- package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
- package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
- package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
- package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
- package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
- package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
- package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
- package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
- package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
- package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
- package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
- package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
- package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
- package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
- package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
- package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
- package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
- package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
- package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
- package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
- package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
- package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
- package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
- package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
- package/dist/canvas/chunks/index-194m3hc3.js +2 -0
- package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
- package/dist/canvas/chunks/index-353m7b0p.js +2 -0
- package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
- package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
- package/dist/canvas/chunks/index-6ceya556.js +332 -0
- package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
- package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
- package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
- package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
- package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
- package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
- package/dist/canvas/chunks/index-nprm96xp.js +16 -0
- package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
- package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
- package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
- package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
- package/dist/canvas/chunks/index-s4j678gj.js +2 -0
- package/dist/canvas/chunks/index-tb1wf525.js +179 -0
- package/dist/canvas/chunks/index-vv56h019.js +2 -0
- package/dist/canvas/chunks/index-w2mra17t.js +207 -0
- package/dist/canvas/chunks/index-wgv7en21.js +232 -0
- package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
- package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
- package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
- package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
- package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
- package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
- package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
- package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
- package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
- package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
- package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
- package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
- package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
- package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
- package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
- package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
- package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
- package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
- package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
- package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
- package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
- package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
- package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
- package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
- package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
- package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
- package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
- package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
- package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
- package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
- package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
- package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
- package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
- package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
- package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
- package/dist/canvas/global.css +389 -229
- package/dist/canvas/index.js +141 -211
- package/dist/canvas/mcp-app.html +4205 -554
- package/dist/canvas/mermaid-entry.js +338 -338
- package/dist/canvas/surface-theme.css +6 -6
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +49 -49
- package/dist/types/client/canvas/BarHint.d.ts +5 -3
- package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
- package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
- package/dist/types/client/canvas/auto-fit.d.ts +4 -5
- package/dist/types/client/mermaid-entry.d.ts +1 -2
- package/dist/types/client/mermaid-render.d.ts +1 -0
- package/dist/types/client/nodes/BoardNode.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
- package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
- package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
- package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
- package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
- package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +3 -1
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +17 -6
- package/dist/types/json-render/server.d.ts +2 -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/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +11 -6
- 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/ops/boards.d.ts +0 -1
- 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/tour-control.d.ts +13 -3
- package/dist/types/server/webpage-node.d.ts +1 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
- package/dist/types/shared/tour.d.ts +8 -0
- package/docs/api-reference.md +2 -2
- package/docs/cli.md +1 -1
- package/docs/design/Chrome.dc.html +32 -6
- package/docs/design/Types1.dc.html +3 -2
- package/docs/design/Types2.dc.html +1 -1
- package/docs/design/Types3.dc.html +1 -1
- package/docs/design/Zoom.dc.html +37 -60
- package/docs/design/canvas.json +1 -1
- package/docs/design.md +169 -4
- package/docs/http-api.md +82 -1
- package/docs/mcp.md +101 -21
- package/docs/node-types.md +38 -3
- package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
- package/docs/plans/plan-019-post-0.9-roadmap.md +402 -13
- package/docs/product-vision-2026-09.md +9 -0
- package/docs/sdk.md +5 -2
- package/package.json +2 -2
- 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 +9418 -2016
- package/skills/pmx-canvas/SKILL.md +19 -21
- package/skills/pmx-canvas/references/full-reference.md +44 -6
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +7 -7
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
- package/src/client/canvas/BarHint.tsx +6 -3
- package/src/client/canvas/CanvasNode.tsx +34 -47
- package/src/client/canvas/CanvasViewport.tsx +6 -7
- package/src/client/canvas/ContextMenu.tsx +63 -3
- package/src/client/canvas/EdgeLayer.tsx +365 -169
- package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
- package/src/client/canvas/Presentation.tsx +53 -3
- package/src/client/canvas/ToolRail.tsx +2 -0
- package/src/client/canvas/TopBar.tsx +24 -9
- package/src/client/canvas/auto-fit.ts +9 -11
- package/src/client/canvas/canvas-area.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +6 -1
- package/src/client/mermaid-entry.ts +6 -20
- package/src/client/mermaid-render.ts +39 -0
- package/src/client/nodes/AxStepControls.tsx +45 -69
- package/src/client/nodes/BoardNode.tsx +65 -9
- package/src/client/nodes/ContextNode.tsx +88 -396
- package/src/client/nodes/ExtAppFrame.tsx +283 -1462
- package/src/client/nodes/FileNode.tsx +92 -81
- package/src/client/nodes/HtmlNode.tsx +8 -36
- package/src/client/nodes/LedgerNode.tsx +31 -17
- package/src/client/nodes/MarkdownNode.tsx +8 -17
- package/src/client/nodes/McpAppNode.tsx +48 -154
- package/src/client/nodes/MermaidNode.tsx +80 -69
- package/src/client/nodes/PromptNode.tsx +12 -14
- package/src/client/nodes/ResponseNode.tsx +8 -6
- package/src/client/nodes/StatusNode.tsx +22 -7
- package/src/client/nodes/StatusSummary.tsx +5 -3
- package/src/client/nodes/TraceNode.tsx +8 -8
- package/src/client/nodes/ViewerFrame.tsx +262 -0
- package/src/client/nodes/ViewerHost.tsx +41 -0
- package/src/client/nodes/WebpageNode.tsx +22 -24
- package/src/client/nodes/iframe-document-url.ts +20 -27
- package/src/client/nodes/primitive-copy.ts +97 -0
- package/src/client/nodes/use-iframe-content-height.ts +34 -10
- package/src/client/nodes/use-surface-frame.ts +22 -11
- package/src/client/nodes/viewer-runtime-state.ts +41 -35
- package/src/client/state/canvas-store.ts +18 -12
- package/src/client/state/intent-bridge.ts +1 -0
- package/src/client/state/presentation.ts +135 -15
- package/src/client/state/sse-bridge.ts +18 -19
- package/src/client/theme/global.css +389 -229
- package/src/client/theme/surface-theme.css +6 -6
- package/src/json-render/charts/tufte-components.tsx +44 -2
- package/src/json-render/renderer/index.css +62 -2
- package/src/json-render/renderer/index.tsx +31 -7
- package/src/json-render/server.ts +3 -2
- 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/board-export.ts +35 -24
- package/src/server/canvas-geometry.ts +1 -0
- package/src/server/canvas-operations.ts +6 -2
- package/src/server/canvas-state.ts +5 -0
- package/src/server/demo-state.json +106 -100
- package/src/server/html-primitives.ts +3 -9
- package/src/server/html-surface.ts +8 -0
- package/src/server/index.ts +30 -12
- 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 +354 -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/boards.ts +1 -3
- package/src/server/operations/ops/nodes.ts +18 -3
- package/src/server/operations/ops/viewport.ts +11 -9
- package/src/server/operations/registry.ts +24 -4
- package/src/server/operations/types.ts +16 -2
- package/src/server/server.ts +17 -59
- package/src/server/tour-control.ts +66 -6
- package/src/server/webpage-node.ts +9 -4
- package/src/shared/ax-surface-protocol.ts +0 -11
- package/src/shared/content-height-reporter.ts +4 -3
- package/src/shared/tour.ts +8 -0
|
@@ -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(
|
|
@@ -12,11 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* This module must never import server.ts or index.ts.
|
|
14
14
|
*/
|
|
15
|
-
import { randomUUID } from 'node:crypto';
|
|
16
15
|
import { z } from 'zod';
|
|
17
16
|
import { normalizeBoardFolder } from '../../../shared/boards.js';
|
|
18
17
|
import { relationVocabularySchema } from '../../../shared/relations.js';
|
|
19
|
-
import { canvasState, type CanvasLayout } from '../../canvas-state.js';
|
|
18
|
+
import { boardCatalogEpoch, canvasState, type CanvasLayout } from '../../canvas-state.js';
|
|
20
19
|
import { openCanvasBoard } from '../../canvas-operations.js';
|
|
21
20
|
import { summarizeNodeForAgentContext } from '../../../shared/agent-context.js';
|
|
22
21
|
import { BRIEF_SUMMARY_LENGTH } from '../../context-brief.js';
|
|
@@ -25,7 +24,6 @@ import { defineOperation, OperationError, type Operation, type OperationContext
|
|
|
25
24
|
|
|
26
25
|
/** The most cards a Home thumbnail draws (the largest first); the rest are noise at that size. */
|
|
27
26
|
const PREVIEW_CARDS = 24;
|
|
28
|
-
export const boardCatalogEpoch = randomUUID();
|
|
29
27
|
let catalogRevision = 0;
|
|
30
28
|
let reloadRevision = 0;
|
|
31
29
|
|
|
@@ -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
|
},
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* This module must never import server.ts or index.ts.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
-
import { tourSchema, rectSchema, cameraSchema, tourStopSchema } from '../../../shared/tour.js';
|
|
16
|
+
import { tourSchema, rectSchema, cameraSchema, tourStopSchema, tourPresenterSchema } from '../../../shared/tour.js';
|
|
17
17
|
import { canvasState } from '../../canvas-state.js';
|
|
18
18
|
import {
|
|
19
19
|
arrangeCanvasNodes,
|
|
@@ -339,6 +339,7 @@ const tourGetOperation = defineOperation({
|
|
|
339
339
|
const tourGoShape = {
|
|
340
340
|
stop: z.number().int().nonnegative().optional().describe('Zero-based stop index to go to'),
|
|
341
341
|
step: z.enum(['next', 'previous']).optional().describe('Step relative to the current stop (clamped at the ends)'),
|
|
342
|
+
presenter: tourPresenterSchema.optional().describe('Document intent order and expected board activation'),
|
|
342
343
|
present: z
|
|
343
344
|
.boolean()
|
|
344
345
|
.optional()
|
|
@@ -360,23 +361,24 @@ const tourGoOperation = defineOperation({
|
|
|
360
361
|
if ((input.stop === undefined) === (input.step === undefined)) {
|
|
361
362
|
throw new OperationError('Give exactly one of stop (index) or step ("next" | "previous").');
|
|
362
363
|
}
|
|
363
|
-
const result = goToTourStop(input.stop ?? (input.step as 'next' | 'previous'));
|
|
364
|
-
ctx.emit('canvas-tour-step', { ...result, present: input.present !== false });
|
|
364
|
+
const result = goToTourStop(input.stop ?? (input.step as 'next' | 'previous'), input.presenter);
|
|
365
|
+
if (!('superseded' in result)) ctx.emit('canvas-tour-step', { ...result, present: input.present !== false });
|
|
365
366
|
return { ok: true, ...result };
|
|
366
367
|
},
|
|
367
368
|
});
|
|
368
369
|
|
|
370
|
+
const tourExitShape = { presenter: tourGoShape.presenter };
|
|
369
371
|
const tourExitOperation = defineOperation({
|
|
370
372
|
name: 'tour.exit',
|
|
371
373
|
mutates: false,
|
|
372
|
-
input: z.object(
|
|
373
|
-
inputShape:
|
|
374
|
+
input: z.object(tourExitShape),
|
|
375
|
+
inputShape: tourExitShape,
|
|
374
376
|
http: { method: 'POST', path: '/api/canvas/tour/exit' },
|
|
375
377
|
mcp: { toolName: 'canvas_tour_exit', description: 'Leave presentation in every viewer.' },
|
|
376
|
-
handler: (
|
|
377
|
-
exitTour();
|
|
378
|
-
ctx.emit('canvas-tour-exit', {});
|
|
379
|
-
return { ok: true };
|
|
378
|
+
handler: (input, ctx) => {
|
|
379
|
+
const result = exitTour(input.presenter);
|
|
380
|
+
if (!('superseded' in result)) ctx.emit('canvas-tour-exit', { ...result });
|
|
381
|
+
return { ok: true, ...result };
|
|
380
382
|
},
|
|
381
383
|
});
|
|
382
384
|
|
|
@@ -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
|
@@ -47,8 +47,8 @@ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'nod
|
|
|
47
47
|
import * as marked from 'marked';
|
|
48
48
|
import { renderWikiMarkdown, resolveWikiLinks, wikiReferences } from './wiki-links.js';
|
|
49
49
|
import { wikiLinksFromData } from '../shared/memory-graph.js';
|
|
50
|
-
import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
51
|
-
import {
|
|
50
|
+
import { type CanvasNodeState, IMAGE_MIME_MAP, boardCatalogEpoch, canvasState } from './canvas-state.js';
|
|
51
|
+
import { boardsPayload } from './operations/ops/boards.js';
|
|
52
52
|
import {
|
|
53
53
|
buildAxBridge,
|
|
54
54
|
buildAxStateBridge,
|
|
@@ -72,7 +72,7 @@ import { buildAgentContextPreamble } from '../shared/agent-context.js';
|
|
|
72
72
|
import { buildCanvasAxSurfaceSnapshot } from './ax-context.js';
|
|
73
73
|
import { resolveNodeAxCapabilities } from './ax-interaction.js';
|
|
74
74
|
import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
|
|
75
|
-
import {
|
|
75
|
+
import { isCanvasTheme } from '../shared/themes.js';
|
|
76
76
|
import { canOpenNodeAsSurface } from '../shared/surface.js';
|
|
77
77
|
import { validateLocalImageFile } from './image-source.js';
|
|
78
78
|
import {
|
|
@@ -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
|
|
|
@@ -1494,44 +1504,6 @@ function handleIframeProbe(): Response {
|
|
|
1494
1504
|
);
|
|
1495
1505
|
}
|
|
1496
1506
|
|
|
1497
|
-
// ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
|
|
1498
|
-
//
|
|
1499
|
-
// The WebKit black-tile recovery trail is otherwise trapped inside host panels
|
|
1500
|
-
// without devtools (the Copilot WKWebView). Clients mirror their bounded
|
|
1501
|
-
// recovery log here; agents/testers read it back over HTTP. In-memory only,
|
|
1502
|
-
// diagnostics-grade: no persistence, no snapshots.
|
|
1503
|
-
const EXT_APP_RECOVERY_LOG_MAX = 500;
|
|
1504
|
-
const EXT_APP_RECOVERY_FIELD_MAX = 300;
|
|
1505
|
-
const extAppRecoveryEntries: Array<Record<string, unknown>> = [];
|
|
1506
|
-
|
|
1507
|
-
/**
|
|
1508
|
-
* Clamp a reported entry to the known trail shape ({ t, nodeId, event,
|
|
1509
|
-
* visibility }) with bounded string lengths. The count trim below caps the
|
|
1510
|
-
* number of entries; this caps bytes per entry, so the in-memory log stays
|
|
1511
|
-
* diagnostics-sized no matter what a client posts.
|
|
1512
|
-
*/
|
|
1513
|
-
function clampExtAppRecoveryEntry(entry: Record<string, unknown>): Record<string, unknown> {
|
|
1514
|
-
const clamped: Record<string, unknown> = {};
|
|
1515
|
-
if (typeof entry.t === 'number' && Number.isFinite(entry.t)) clamped.t = entry.t;
|
|
1516
|
-
for (const key of ['nodeId', 'event', 'visibility'] as const) {
|
|
1517
|
-
const value = entry[key];
|
|
1518
|
-
if (typeof value === 'string') clamped[key] = value.slice(0, EXT_APP_RECOVERY_FIELD_MAX);
|
|
1519
|
-
}
|
|
1520
|
-
return clamped;
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
function handleExtAppRecoveryReport(entries: unknown): Response {
|
|
1524
|
-
if (!Array.isArray(entries)) return responseJson({ ok: false, error: 'entries must be an array' }, 400);
|
|
1525
|
-
for (const entry of entries.slice(0, 100)) {
|
|
1526
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
1527
|
-
extAppRecoveryEntries.push(clampExtAppRecoveryEntry(entry as Record<string, unknown>));
|
|
1528
|
-
}
|
|
1529
|
-
if (extAppRecoveryEntries.length > EXT_APP_RECOVERY_LOG_MAX) {
|
|
1530
|
-
extAppRecoveryEntries.splice(0, extAppRecoveryEntries.length - EXT_APP_RECOVERY_LOG_MAX);
|
|
1531
|
-
}
|
|
1532
|
-
return responseJson({ ok: true, total: extAppRecoveryEntries.length });
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
1507
|
// ── Node surfaces ("Open as site") ─────────────────────────────
|
|
1536
1508
|
//
|
|
1537
1509
|
// One stable, node-addressable URL — /api/canvas/surface/:nodeId — that serves
|
|
@@ -1831,14 +1803,7 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
1831
1803
|
);
|
|
1832
1804
|
|
|
1833
1805
|
const themeValue = url.searchParams.get('theme');
|
|
1834
|
-
|
|
1835
|
-
// canvas themes (midnight, sepia, …) collapse to their dark/light scheme.
|
|
1836
|
-
const theme =
|
|
1837
|
-
themeValue === 'dark' || themeValue === 'light' || themeValue === 'high-contrast'
|
|
1838
|
-
? themeValue
|
|
1839
|
-
: isCanvasTheme(themeValue)
|
|
1840
|
-
? canvasThemeScheme(themeValue)
|
|
1841
|
-
: undefined;
|
|
1806
|
+
const theme = themeValue === 'dark' || themeValue === 'light' || isCanvasTheme(themeValue) ? themeValue : 'harbor';
|
|
1842
1807
|
const title = (node.data.title as string) || node.id;
|
|
1843
1808
|
// Devtools panel is double-gated: the operator must opt in via the env flag
|
|
1844
1809
|
// AND the request must carry ?devtools=1. Off by default in all normal runs.
|
|
@@ -2264,6 +2229,9 @@ function buildWorkbenchConnectSnapshot(
|
|
|
2264
2229
|
timestamp: new Date().toISOString(),
|
|
2265
2230
|
},
|
|
2266
2231
|
});
|
|
2232
|
+
// Establish ownership in this ordered stream before layouts and live tour
|
|
2233
|
+
// commands. The separate catalog HTTP response may arrive after either.
|
|
2234
|
+
events.push({ event: 'boards-changed', payload: boardsPayload() });
|
|
2267
2235
|
if (primaryWorkbenchPath) {
|
|
2268
2236
|
events.push({
|
|
2269
2237
|
event: 'workbench-open',
|
|
@@ -3561,16 +3529,6 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3561
3529
|
return handleJsonRenderView(url);
|
|
3562
3530
|
}
|
|
3563
3531
|
|
|
3564
|
-
if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'POST') {
|
|
3565
|
-
const body = await readJson(req);
|
|
3566
|
-
if (body === null) return malformedJsonResponse();
|
|
3567
|
-
return handleExtAppRecoveryReport(body.entries);
|
|
3568
|
-
}
|
|
3569
|
-
|
|
3570
|
-
if (url.pathname === '/api/canvas/debug/ext-app-recovery' && req.method === 'GET') {
|
|
3571
|
-
return responseJson({ ok: true, entries: extAppRecoveryEntries });
|
|
3572
|
-
}
|
|
3573
|
-
|
|
3574
3532
|
if (url.pathname === '/api/canvas/frame-documents' && req.method === 'POST') {
|
|
3575
3533
|
return handleCreateFrameDocument(req);
|
|
3576
3534
|
}
|
|
@@ -8,19 +8,38 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This module must never import server.ts or index.ts.
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
derivedTour,
|
|
13
|
+
resolveStop,
|
|
14
|
+
tourPresenterSchema,
|
|
15
|
+
tourStopSchema,
|
|
16
|
+
type Camera,
|
|
17
|
+
type Tour,
|
|
18
|
+
type TourPresenter,
|
|
19
|
+
type TourStop,
|
|
20
|
+
} from '../shared/tour.js';
|
|
21
|
+
import { boardCatalogEpoch, canvasState } from './canvas-state.js';
|
|
13
22
|
import { getClientViewportSize } from './canvas-operations.js';
|
|
14
23
|
import { OperationError } from './operations/types.js';
|
|
15
24
|
|
|
16
25
|
export type TourStep = number | 'next' | 'previous';
|
|
17
26
|
|
|
18
|
-
export interface
|
|
27
|
+
export interface TourDelivery {
|
|
28
|
+
catalogEpoch: string;
|
|
29
|
+
boardGeneration: number;
|
|
30
|
+
sequence: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TourStepResult extends TourDelivery {
|
|
19
34
|
index: number;
|
|
20
35
|
total: number;
|
|
21
36
|
stop: TourStop;
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
export interface TourSuperseded {
|
|
40
|
+
superseded: true;
|
|
41
|
+
}
|
|
42
|
+
|
|
24
43
|
export interface TourState {
|
|
25
44
|
tour: Tour;
|
|
26
45
|
derived: boolean;
|
|
@@ -28,6 +47,35 @@ export interface TourState {
|
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
let cursor: { boardGeneration: number; tour: string; index: number } | null = null;
|
|
50
|
+
// One order for both HTTP acknowledgements and event delivery in this server run.
|
|
51
|
+
let sequence = 0;
|
|
52
|
+
let presenterGeneration = -1;
|
|
53
|
+
const presenterIntents = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
function claimPresenter(presenter: TourPresenter | undefined): boolean {
|
|
56
|
+
if (presenter === undefined) return true;
|
|
57
|
+
const { clientId, intent, catalogEpoch, boardGeneration } = tourPresenterSchema.parse(presenter);
|
|
58
|
+
if (catalogEpoch !== boardCatalogEpoch || boardGeneration !== canvasState.boardGeneration) return false;
|
|
59
|
+
if (presenterGeneration !== boardGeneration) {
|
|
60
|
+
presenterIntents.clear();
|
|
61
|
+
presenterGeneration = boardGeneration;
|
|
62
|
+
}
|
|
63
|
+
const previous = presenterIntents.get(clientId);
|
|
64
|
+
if (previous !== undefined && intent <= previous) return false;
|
|
65
|
+
// Never evict an identity: that would make its delayed requests valid again.
|
|
66
|
+
if (previous === undefined && presenterIntents.size >= 4096)
|
|
67
|
+
throw new OperationError(
|
|
68
|
+
'Too many presentation documents on this board activation. Reopen the board to continue.',
|
|
69
|
+
409,
|
|
70
|
+
);
|
|
71
|
+
// Even a later invalid stop supersedes older commands. Claim before resolving it.
|
|
72
|
+
presenterIntents.set(clientId, intent);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nextDelivery(): TourDelivery {
|
|
77
|
+
return { catalogEpoch: boardCatalogEpoch, boardGeneration: canvasState.boardGeneration, sequence: ++sequence };
|
|
78
|
+
}
|
|
31
79
|
|
|
32
80
|
export function currentTour(): TourState {
|
|
33
81
|
const saved = canvasState.getTour();
|
|
@@ -44,7 +92,10 @@ function tourPosition(tour: Tour): number | null {
|
|
|
44
92
|
}
|
|
45
93
|
|
|
46
94
|
/** Move the shared cursor. `next`/`previous` clamp at the ends; an explicit index must exist. */
|
|
47
|
-
export function goToTourStop(step: TourStep): TourStepResult
|
|
95
|
+
export function goToTourStop(step: TourStep): TourStepResult;
|
|
96
|
+
export function goToTourStop(step: TourStep, presenter: TourPresenter | undefined): TourStepResult | TourSuperseded;
|
|
97
|
+
export function goToTourStop(step: TourStep, presenter?: TourPresenter): TourStepResult | TourSuperseded {
|
|
98
|
+
if (!claimPresenter(presenter)) return { superseded: true };
|
|
48
99
|
const { tour, position: at } = currentTour();
|
|
49
100
|
const { stops } = tour;
|
|
50
101
|
if (stops.length === 0) {
|
|
@@ -56,11 +107,20 @@ export function goToTourStop(step: TourStep): TourStepResult {
|
|
|
56
107
|
else if (Number.isInteger(step) && step >= 0 && step < stops.length) index = step;
|
|
57
108
|
else throw new OperationError(`Tour stop ${step} does not exist (stops 0–${stops.length - 1}).`, 400);
|
|
58
109
|
cursor = { boardGeneration: canvasState.boardGeneration, tour: JSON.stringify(tour), index };
|
|
59
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
...nextDelivery(),
|
|
112
|
+
index,
|
|
113
|
+
total: stops.length,
|
|
114
|
+
stop: stops[index] as TourStop,
|
|
115
|
+
};
|
|
60
116
|
}
|
|
61
117
|
|
|
62
|
-
export function exitTour():
|
|
118
|
+
export function exitTour(): TourDelivery;
|
|
119
|
+
export function exitTour(presenter: TourPresenter | undefined): TourDelivery | TourSuperseded;
|
|
120
|
+
export function exitTour(presenter?: TourPresenter): TourDelivery | TourSuperseded {
|
|
121
|
+
if (!claimPresenter(presenter)) return { superseded: true };
|
|
63
122
|
cursor = null;
|
|
123
|
+
return nextDelivery();
|
|
64
124
|
}
|
|
65
125
|
|
|
66
126
|
/**
|
|
@@ -386,34 +386,37 @@ export function checkWebpageHop(originScope: AddressScope, hopScope: AddressScop
|
|
|
386
386
|
|
|
387
387
|
export async function fetchWebpageSnapshot(
|
|
388
388
|
inputUrl: string,
|
|
389
|
-
options: { resolveHost?: ResolveHost; timeoutMs?: number } = {},
|
|
389
|
+
options: { resolveHost?: ResolveHost; timeoutMs?: number; signal?: AbortSignal } = {},
|
|
390
390
|
): Promise<WebpageSnapshot> {
|
|
391
|
+
options.signal?.throwIfAborted();
|
|
391
392
|
const url = normalizeWebpageUrl(inputUrl);
|
|
392
393
|
const controller = new AbortController();
|
|
394
|
+
const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
|
393
395
|
const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
394
396
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
395
397
|
const resolveHost = options.resolveHost ?? ((host) => lookup(host, { all: true }));
|
|
396
398
|
|
|
397
399
|
try {
|
|
398
400
|
// Redirects are followed by hand so every hop's host is checked before it is fetched.
|
|
399
|
-
let resolved = await abortable(resolveHop(new URL(url), resolveHost),
|
|
401
|
+
let resolved = await abortable(resolveHop(new URL(url), resolveHost), signal);
|
|
400
402
|
const originScope = resolved.scope;
|
|
401
403
|
let hopUrl = url;
|
|
402
404
|
let response: Response;
|
|
403
405
|
for (let hop = 0; ; hop++) {
|
|
404
406
|
checkWebpageHop(originScope, resolved.scope, hopUrl);
|
|
405
|
-
response = await requestHop(new URL(hopUrl), resolved,
|
|
407
|
+
response = await requestHop(new URL(hopUrl), resolved, signal);
|
|
406
408
|
const location = response.headers.get('location');
|
|
407
409
|
if (response.status < 300 || response.status >= 400 || !location) break;
|
|
408
410
|
await response.body?.cancel();
|
|
409
411
|
if (hop === MAX_REDIRECTS) throw new WebpageFetchError(`Too many redirects while fetching ${url}.`);
|
|
410
412
|
hopUrl = normalizeWebpageUrl(new URL(location, hopUrl).toString());
|
|
411
|
-
resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost),
|
|
413
|
+
resolved = await abortable(resolveHop(new URL(hopUrl), resolveHost), signal);
|
|
412
414
|
}
|
|
413
415
|
|
|
414
416
|
const contentType = response.headers.get('content-type');
|
|
415
417
|
const responseUrl = hopUrl;
|
|
416
418
|
const body = await readBoundedBody(response);
|
|
419
|
+
signal.throwIfAborted();
|
|
417
420
|
|
|
418
421
|
if (!response.ok) {
|
|
419
422
|
throw new WebpageFetchError(`Request failed with ${response.status} ${response.statusText}`.trim(), {
|
|
@@ -450,6 +453,8 @@ export async function fetchWebpageSnapshot(
|
|
|
450
453
|
frameBlockedReason,
|
|
451
454
|
};
|
|
452
455
|
} catch (error) {
|
|
456
|
+
// Caller cancellation is not a fetch failure or our internal timeout.
|
|
457
|
+
options.signal?.throwIfAborted();
|
|
453
458
|
if (error instanceof WebpageFetchError) throw error;
|
|
454
459
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
455
460
|
throw new WebpageFetchError(`Timed out after ${timeoutMs}ms while fetching ${url}.`);
|