pmx-canvas 0.1.36 → 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.
Files changed (85) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/Readme.md +2 -2
  3. package/dist/json-render/index.js +89 -334
  4. package/dist/types/mcp/canvas-access.d.ts +5 -171
  5. package/dist/types/server/ax-state-manager.d.ts +256 -0
  6. package/dist/types/server/ax-state.d.ts +1 -1
  7. package/dist/types/server/canvas-operations.d.ts +1 -12
  8. package/dist/types/server/canvas-state.d.ts +3 -23
  9. package/dist/types/server/index.d.ts +6 -24
  10. package/dist/types/server/operations/composites.d.ts +121 -0
  11. package/dist/types/server/operations/http.d.ts +7 -0
  12. package/dist/types/server/operations/index.d.ts +8 -0
  13. package/dist/types/server/operations/invoker.d.ts +13 -0
  14. package/dist/types/server/operations/mcp.d.ts +15 -0
  15. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  16. package/dist/types/server/operations/ops/app.d.ts +33 -0
  17. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  18. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  19. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  20. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  21. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  22. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  23. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  24. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  25. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  26. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  27. package/dist/types/server/operations/ops/query.d.ts +2 -0
  28. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  29. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  30. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  31. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  32. package/dist/types/server/operations/registry.d.ts +15 -0
  33. package/dist/types/server/operations/types.d.ts +116 -0
  34. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  35. package/docs/RELEASE.md +5 -0
  36. package/docs/adr-001-bun-only-runtime.md +46 -0
  37. package/docs/api-stability.md +57 -0
  38. package/docs/ax-state-contract.md +72 -0
  39. package/docs/mcp.md +60 -11
  40. package/docs/plans/plan-005-operation-registry.md +84 -0
  41. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  42. package/docs/plans/plan-007-ax-domain.md +99 -0
  43. package/docs/plans/plan-008-registry-finish.md +91 -0
  44. package/docs/tech-debt-assessment-2026-06.md +90 -0
  45. package/package.json +3 -3
  46. package/skills/pmx-canvas/SKILL.md +192 -186
  47. package/skills/pmx-canvas/evals/evals.json +3 -3
  48. package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
  49. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +4 -5
  50. package/src/cli/agent.ts +52 -31
  51. package/src/mcp/canvas-access.ts +30 -830
  52. package/src/mcp/server.ts +162 -2014
  53. package/src/server/ax-state-manager.ts +808 -0
  54. package/src/server/ax-state.ts +2 -2
  55. package/src/server/canvas-operations.ts +2 -328
  56. package/src/server/canvas-schema.ts +2 -2
  57. package/src/server/canvas-state.ts +95 -465
  58. package/src/server/index.ts +54 -190
  59. package/src/server/operations/composites.ts +355 -0
  60. package/src/server/operations/http.ts +103 -0
  61. package/src/server/operations/index.ts +65 -0
  62. package/src/server/operations/invoker.ts +87 -0
  63. package/src/server/operations/mcp.ts +221 -0
  64. package/src/server/operations/ops/annotation.ts +60 -0
  65. package/src/server/operations/ops/app.ts +447 -0
  66. package/src/server/operations/ops/ax-await.ts +216 -0
  67. package/src/server/operations/ops/ax-shared.ts +38 -0
  68. package/src/server/operations/ops/ax-state.ts +249 -0
  69. package/src/server/operations/ops/ax-timeline.ts +381 -0
  70. package/src/server/operations/ops/ax-work.ts +635 -0
  71. package/src/server/operations/ops/batch.ts +365 -0
  72. package/src/server/operations/ops/edges.ts +166 -0
  73. package/src/server/operations/ops/groups.ts +176 -0
  74. package/src/server/operations/ops/json-render.ts +691 -0
  75. package/src/server/operations/ops/nodes.ts +1047 -0
  76. package/src/server/operations/ops/query.ts +281 -0
  77. package/src/server/operations/ops/snapshots.ts +366 -0
  78. package/src/server/operations/ops/validate.ts +37 -0
  79. package/src/server/operations/ops/viewport.ts +219 -0
  80. package/src/server/operations/ops/webview.ts +339 -0
  81. package/src/server/operations/registry.ts +79 -0
  82. package/src/server/operations/types.ts +150 -0
  83. package/src/server/operations/webview-runner.ts +77 -0
  84. package/src/server/server.ts +158 -2255
  85. package/src/server/web-artifacts.ts +6 -2
@@ -47,7 +47,7 @@ import type {
47
47
  ListResourceTemplatesResult,
48
48
  ListToolsResult,
49
49
  } from '@modelcontextprotocol/sdk/types.js';
50
- import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
50
+ import { type CanvasAnnotation, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
51
51
  import { buildAxBridge, buildAxStateBridge, buildContentHeightReporter, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
52
52
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
53
53
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
@@ -60,99 +60,54 @@ import {
60
60
  listMcpAppResources,
61
61
  listMcpAppResourceTemplates,
62
62
  listMcpAppTools,
63
- openMcpApp,
64
63
  readMcpAppResource,
65
- type ExternalMcpTransportConfig,
66
64
  } from './mcp-app-runtime.js';
67
65
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
68
- import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
69
- import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js';
70
- import {
71
- buildCanvasSummary,
72
- serializeCanvasLayout,
73
- serializeCanvasLayoutWithBlobSummaries,
74
- serializeCanvasNode,
75
- serializeCanvasNodeWithBlobSummaries,
76
- summarizeCanvasAnnotation,
77
- } from './canvas-serialization.js';
66
+ import { mutationHistory } from './mutation-history.js';
67
+ import { summarizeCanvasAnnotation } from './canvas-serialization.js';
78
68
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
79
69
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
80
70
  import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from './ax-context.js';
81
- import { applyAxInteraction, resolveNodeAxCapabilities, normalizeNodeAxCapabilities } from './ax-interaction.js';
82
- import { isAxEventKind, isAxEvidenceKind, isAxActivityKind } from './ax-state.js';
83
- import { waitForAxResolution, AX_WAIT_MAX_MS } from './ax-wait.js';
71
+ import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
72
+ import { isAxEvidenceKind, isAxActivityKind } from './ax-state.js';
84
73
  import type {
85
74
  PmxAxEvidenceKind,
86
75
  PmxAxPolicy,
87
76
  PmxAxReviewAnchorType,
88
77
  PmxAxReviewKind,
89
- PmxAxReviewRegion,
90
78
  PmxAxReviewSeverity,
91
- PmxAxReviewStatus,
92
79
  PmxAxSource,
93
80
  PmxAxWorkItemStatus,
94
81
  } from './ax-state.js';
95
82
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
96
83
  import { validateLocalImageFile } from './image-source.js';
97
84
  import {
98
- addCanvasNode,
99
- addCanvasEdge,
100
- MARKDOWN_NODE_DEFAULT_SIZE,
101
- MCP_APP_NODE_DEFAULT_SIZE,
102
- IMAGE_NODE_DEFAULT_SIZE,
103
- LEDGER_NODE_DEFAULT_SIZE,
104
85
  applyCanvasNodeUpdates,
105
- appendCanvasJsonRenderStream,
106
- buildStructuredNodeUpdate,
107
- arrangeCanvasNodes,
108
- clearCanvas,
109
- createCanvasGraphNode,
110
- createCanvasGroup,
111
- createCanvasJsonRenderNode,
112
- createCanvasStreamingJsonRenderNode,
113
- deleteCanvasSnapshot,
114
- executeCanvasBatch,
115
- fitCanvasView,
116
- gcCanvasSnapshots,
117
- groupCanvasNodes,
118
- listCanvasSnapshots,
119
86
  refreshCanvasWebpageNode,
120
- removeCanvasNode,
121
- removeCanvasEdge,
122
- resolveHtmlContent,
123
- restoreCanvasSnapshot,
124
- saveCanvasSnapshot,
125
- scheduleCodeGraphRecompute,
126
87
  primeCanvasRuntimeBackends,
127
88
  setCanvasLayoutUpdateEmitter,
128
89
  syncCanvasRuntimeBackends,
129
- setCanvasContextPins,
130
- ungroupCanvasNodes,
131
- validateCanvasNodePatch,
132
- hasStructuredNodeUpdateFields,
133
- hasTraceNodeDataFields,
134
- mergeTraceNodeDataFields,
135
90
  } from './canvas-operations.js';
136
- import { validateCanvasLayout } from './canvas-validation.js';
137
- import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
138
- import { buildHtmlPrimitive, getHtmlPrimitiveSemanticMetadata, isHtmlPrimitiveKind } from './html-primitives.js';
91
+ import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
92
+ import { setWebviewRunner } from './operations/webview-runner.js';
93
+ import {
94
+ closeNodeAppSession,
95
+ nodeAppSessionId,
96
+ } from './operations/ops/nodes.js';
139
97
  import {
140
98
  EXCALIDRAW_READ_CHECKPOINT_TOOL,
141
99
  EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
142
100
  buildExcalidrawCheckpointId,
143
- buildExcalidrawOpenMcpAppInput,
144
101
  buildExcalidrawRestoreCheckpointToolInput,
145
102
  ensureExcalidrawCheckpointId,
146
103
  getExcalidrawCheckpointIdFromToolResult,
147
104
  isExcalidrawCreateView,
148
105
  } from './diagram-presets.js';
149
106
  import { traceManager } from './trace-manager.js';
150
- import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
151
107
  import {
152
108
  buildJsonRenderViewerHtml,
153
109
  } from '../json-render/server.js';
154
110
  import {
155
- WEBPAGE_NODE_DEFAULT_SIZE,
156
111
  normalizeWebpageUrl,
157
112
  } from './webpage-node.js';
158
113
  import type { JsonRenderSpec } from '../json-render/server.js';
@@ -172,6 +127,52 @@ let primaryWorkbenchAutoOpenEnabled = true;
172
127
  const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
173
128
  let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
174
129
 
130
+ // Operation-registry SSE wiring (plan-005): the registry never imports this
131
+ // module — the workbench event emitter is injected here, mirroring the
132
+ // setCanvasLayoutUpdateEmitter pattern. Wired at module top level so local
133
+ // (in-process) MCP/SDK invocations emit even without startCanvasServer().
134
+ setOperationEventEmitter((event, payload) => {
135
+ emitPrimaryWorkbenchEvent(event, payload);
136
+ });
137
+
138
+ // Webview-runner wiring (plan-008 Wave 3): the webview ops never import this
139
+ // module — the Bun.WebView automation runner is injected here, mirroring the
140
+ // setOperationEventEmitter pattern. The closures call the real automation
141
+ // functions (declared below, hoisted) so a webview op resolves to the same
142
+ // machinery the legacy hand-written tools/routes used. `screenshot` stays out —
143
+ // it returns binary and remains the standalone canvas_screenshot tool.
144
+ setWebviewRunner({
145
+ status: () => getCanvasAutomationWebViewStatus(),
146
+ start: async (options) => {
147
+ const url = currentWorkbenchUrl();
148
+ if (!url) {
149
+ // Mirrors the legacy 503 "server not running" branch: no URL → not a
150
+ // start failure but a precondition error. Surface it through the
151
+ // error-shaped result so the op can map it to the same 503 wire body
152
+ // (no webview field, matching the legacy handler).
153
+ return {
154
+ ok: false as const,
155
+ serverNotRunning: true as const,
156
+ error: 'Canvas server is not running.',
157
+ };
158
+ }
159
+ try {
160
+ const webview = await startCanvasAutomationWebView(url, options);
161
+ return { ok: true as const, webview };
162
+ } catch (error) {
163
+ return {
164
+ ok: false as const,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ // 500-vs-501 is read off webview.supported (the status), so no separate field.
167
+ webview: getCanvasAutomationWebViewStatus(),
168
+ };
169
+ }
170
+ },
171
+ stop: () => stopCanvasAutomationWebView(),
172
+ resize: (width, height) => resizeCanvasAutomationWebView(width, height),
173
+ evaluate: (expression) => evaluateCanvasAutomationWebView(expression),
174
+ });
175
+
175
176
  function normalizeGraphViewerSpec(
176
177
  node: { type: string; data: Record<string, unknown> },
177
178
  spec: JsonRenderSpec,
@@ -719,155 +720,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
719
720
  return value !== null && typeof value === 'object' && !Array.isArray(value);
720
721
  }
721
722
 
722
- function pickFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
723
- const value = record[key];
724
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
725
- }
726
-
727
- function getRecord(value: unknown): Record<string, unknown> | undefined {
728
- return isRecord(value) ? value : undefined;
729
- }
730
-
731
- function pickPositiveNumber(record: Record<string, unknown>, key: string): number | undefined {
732
- const value = pickFiniteNumber(record, key);
733
- return value !== undefined && value > 0 ? value : undefined;
734
- }
735
-
736
- function normalizeGeometryInput(body: Record<string, unknown>): {
737
- x?: number;
738
- y?: number;
739
- width?: number;
740
- height?: number;
741
- position?: { x?: number; y?: number };
742
- size?: { width?: number; height?: number };
743
- } {
744
- const position = getRecord(body.position);
745
- const size = getRecord(body.size);
746
- return {
747
- ...(pickFiniteNumber(body, 'x') !== undefined ? { x: pickFiniteNumber(body, 'x') } : {}),
748
- ...(pickFiniteNumber(body, 'y') !== undefined ? { y: pickFiniteNumber(body, 'y') } : {}),
749
- ...(pickFiniteNumber(body, 'width') !== undefined ? { width: pickFiniteNumber(body, 'width') } : {}),
750
- ...(pickFiniteNumber(body, 'height') !== undefined ? { height: pickFiniteNumber(body, 'height') } : {}),
751
- ...(position ? {
752
- position: {
753
- ...(pickFiniteNumber(position, 'x') !== undefined ? { x: pickFiniteNumber(position, 'x') } : {}),
754
- ...(pickFiniteNumber(position, 'y') !== undefined ? { y: pickFiniteNumber(position, 'y') } : {}),
755
- },
756
- } : {}),
757
- ...(size ? {
758
- size: {
759
- ...(pickFiniteNumber(size, 'width') !== undefined ? { width: pickFiniteNumber(size, 'width') } : {}),
760
- ...(pickFiniteNumber(size, 'height') !== undefined ? { height: pickFiniteNumber(size, 'height') } : {}),
761
- },
762
- } : {}),
763
- };
764
- }
765
-
766
- function resolveCreateGeometry(body: Record<string, unknown>): {
767
- x?: number;
768
- y?: number;
769
- width?: number;
770
- height?: number;
771
- } {
772
- const geometry = normalizeGeometryInput(body);
773
- const x = geometry.x ?? geometry.position?.x;
774
- const y = geometry.y ?? geometry.position?.y;
775
- const width = geometry.width ?? geometry.size?.width;
776
- const height = geometry.height ?? geometry.size?.height;
777
- return {
778
- ...(x !== undefined ? { x } : {}),
779
- ...(y !== undefined ? { y } : {}),
780
- ...(width !== undefined ? { width } : {}),
781
- ...(height !== undefined ? { height } : {}),
782
- };
783
- }
784
-
785
- function resolvePatchGeometry(
786
- body: Record<string, unknown>,
787
- existing: CanvasNodeState,
788
- ): {
789
- position?: { x: number; y: number };
790
- size?: { width: number; height: number };
791
- } {
792
- const geometry = normalizeGeometryInput(body);
793
- const x = geometry.x ?? geometry.position?.x;
794
- const y = geometry.y ?? geometry.position?.y;
795
- const width = geometry.width ?? geometry.size?.width;
796
- const height = geometry.height ?? geometry.size?.height;
797
- return {
798
- ...(x !== undefined || y !== undefined
799
- ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
800
- : {}),
801
- ...(width !== undefined || height !== undefined
802
- ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
803
- : {}),
804
- };
805
- }
806
-
807
- function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> | null {
808
- if (!Array.isArray(value)) return null;
809
- if (value.some((item) => !isRecord(item))) return null;
810
- return value as Array<Record<string, unknown>>;
811
- }
812
-
813
- type StringListField = { value?: string[]; error?: string };
814
-
815
- function parseStringListField(field: string, value: unknown): StringListField {
816
- if (value === undefined) return {};
817
- if (!Array.isArray(value)) return { error: `"${field}" must be an array of node IDs.` };
818
- const invalid = value.find((item) => typeof item !== 'string' || item.trim().length === 0);
819
- if (invalid !== undefined) return { error: `"${field}" must contain only non-empty node IDs.` };
820
- return { value };
821
- }
822
-
823
- function pickGroupChildIds(body: Record<string, unknown>): StringListField {
824
- if ('children' in body) return parseStringListField('children', body.children);
825
- if ('childIds' in body) return parseStringListField('childIds', body.childIds);
826
- const data = isRecord(body.data) ? body.data : undefined;
827
- return data && 'children' in data ? parseStringListField('data.children', data.children) : {};
828
- }
829
-
830
- function validateGroupChildIds(groupId: string, childIds: string[]): string | null {
831
- const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
832
- if (missingChildIds.length > 0) {
833
- return `Missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`;
834
- }
835
- const invalidChildIds = childIds.filter((id) => {
836
- const node = canvasState.getNode(id);
837
- return id === groupId || node?.type === 'group';
838
- });
839
- if (invalidChildIds.length > 0) {
840
- return `Invalid group child ID${invalidChildIds.length === 1 ? '' : 's'}: ${invalidChildIds.join(', ')}.`;
841
- }
842
- return null;
843
- }
844
-
845
- function setGroupChildrenFromApi(groupId: string, childIds: string[]): boolean {
846
- const group = canvasState.getNode(groupId);
847
- if (!group || group.type !== 'group') return false;
848
-
849
- const dataChildIds = Array.isArray(group.data.children)
850
- ? group.data.children.filter((id): id is string => typeof id === 'string')
851
- : [];
852
- const parentBackrefIds = canvasState.getLayout().nodes
853
- .filter((node) => node.id !== groupId && node.data.parentGroup === groupId)
854
- .map((node) => node.id);
855
- const currentChildIds = [...new Set([...dataChildIds, ...parentBackrefIds])];
856
- if (currentChildIds.length > 0) {
857
- if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
858
- canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
859
- }
860
- canvasState.ungroupNodes(groupId);
861
- }
862
- if (childIds.length === 0) return true;
863
-
864
- const latestGroup = canvasState.getNode(groupId);
865
- return canvasState.groupNodes(groupId, childIds, {
866
- preservePositions: true,
867
- keepGroupFrame: latestGroup?.data.frameMode === 'manual',
868
- });
869
- }
870
-
871
723
  function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
872
724
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
873
725
  const storedCheckpointId = appCheckpoint?.id;
@@ -1075,34 +927,6 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
1075
927
  }
1076
928
  }
1077
929
 
1078
- /**
1079
- * Like {@link readJson}, but PRESERVES a top-level JSON array. For endpoints that
1080
- * accept either an object or a bare array (e.g. `/api/canvas/batch`, whose CLI
1081
- * help and handler both document a bare `[...]` form). readJson coerces arrays to
1082
- * `{}` so object-shaped handlers never crash on `body.field`; this variant keeps
1083
- * the array so the handler's array branch can run. Empty/whitespace/malformed
1084
- * bodies still resolve to `{}`.
1085
- */
1086
- async function readJsonObjectOrArray(req: Request): Promise<Record<string, unknown> | unknown[]> {
1087
- let text = '';
1088
- try {
1089
- text = await req.text();
1090
- } catch (error) {
1091
- logWorkbenchWarning('readJson', error);
1092
- return {};
1093
- }
1094
- if (!text.trim()) return {};
1095
- try {
1096
- const value = JSON.parse(text) as unknown;
1097
- if (Array.isArray(value)) return value;
1098
- if (!value || typeof value !== 'object') return {};
1099
- return value as Record<string, unknown>;
1100
- } catch (error) {
1101
- logWorkbenchWarning('readJson', error);
1102
- return {};
1103
- }
1104
- }
1105
-
1106
930
  function htmlEscape(value: string): string {
1107
931
  return value
1108
932
  .replaceAll('&', '&amp;')
@@ -1643,14 +1467,6 @@ async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
1643
1467
  return responseJson({ ok: true, annotation: summarizeCanvasAnnotation(annotation) });
1644
1468
  }
1645
1469
 
1646
- function handleCanvasRemoveAnnotation(id: string): Response {
1647
- const decodedId = decodeURIComponent(id);
1648
- const removed = canvasState.removeAnnotation(decodedId);
1649
- if (!removed) return responseJson({ ok: false, error: `Annotation "${decodedId}" not found.` }, 404);
1650
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1651
- return responseJson({ ok: true, removed: decodedId });
1652
- }
1653
-
1654
1470
  // ── Serve image file for image nodes ─────────────────────────
1655
1471
  async function handleCanvasImage(pathname: string): Promise<Response> {
1656
1472
  const nodeId = pathname.replace('/api/canvas/image/', '');
@@ -1687,384 +1503,6 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1687
1503
  });
1688
1504
  }
1689
1505
 
1690
- // ── Add node from client ─────────────────────────────────────
1691
- const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'html', 'group']);
1692
-
1693
- function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1694
- const serialized = serializeCanvasNode(node);
1695
- return {
1696
- ok: true,
1697
- node: serialized,
1698
- ...serialized,
1699
- // `nodeId` aliases `id` so HTTP/CLI node-create responses match the MCP
1700
- // createdNodePayload — agents using either key (or a cached schema) work.
1701
- nodeId: node.id,
1702
- };
1703
- }
1704
-
1705
- function withContextPinReadState(node: CanvasNodeState): CanvasNodeState {
1706
- return {
1707
- ...node,
1708
- pinned: node.pinned || canvasState.contextPinnedNodeIds.has(node.id),
1709
- };
1710
- }
1711
-
1712
- function withContextPinLayoutReadState(layout: CanvasLayout): CanvasLayout {
1713
- return {
1714
- ...layout,
1715
- nodes: layout.nodes.map(withContextPinReadState),
1716
- };
1717
- }
1718
-
1719
- async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
1720
- const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
1721
- ? body.url
1722
- : typeof body.content === 'string'
1723
- ? body.content
1724
- : '';
1725
-
1726
- let normalizedUrl: string;
1727
- try {
1728
- normalizedUrl = normalizeWebpageUrl(rawUrl);
1729
- } catch (error) {
1730
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
1731
- }
1732
-
1733
- const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1734
- ? body.data as Record<string, unknown>
1735
- : undefined;
1736
- const geometry = resolveCreateGeometry(body);
1737
- const { id, node } = addCanvasNode({
1738
- type: 'webpage',
1739
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1740
- content: normalizedUrl,
1741
- ...(extraData ? { data: extraData } : {}),
1742
- ...(body.strictSize === true ? { strictSize: true } : {}),
1743
- ...geometry,
1744
- ...(geometry.width === undefined ? { width: WEBPAGE_NODE_DEFAULT_SIZE.width } : {}),
1745
- ...(geometry.height === undefined ? { height: WEBPAGE_NODE_DEFAULT_SIZE.height } : {}),
1746
- });
1747
-
1748
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1749
- const refreshed = await refreshCanvasWebpageNode(id);
1750
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1751
- const created = canvasState.getNode(id) ?? node;
1752
- return responseJson({
1753
- ...buildNodeResponse(created),
1754
- fetch: refreshed.ok
1755
- ? { ok: true }
1756
- : { ok: false, error: refreshed.error ?? 'Failed to fetch webpage content.' },
1757
- ...(refreshed.ok ? {} : { error: refreshed.error }),
1758
- });
1759
- }
1760
-
1761
- async function handleCanvasAddNode(req: Request): Promise<Response> {
1762
- const body = await readJson(req);
1763
- const queryType = new URL(req.url).searchParams.get('type');
1764
- // Report #50: require a resolvable type rather than silently defaulting to a
1765
- // markdown node — an empty / type-less body created a phantom node before.
1766
- const type = typeof body.type === 'string' ? body.type : (queryType || '');
1767
- if (!type) {
1768
- return responseJson({
1769
- ok: false,
1770
- error: `node creation requires a 'type' — pass it in the JSON body ({ "type": "markdown", ... }) or as a ?type= query param. Valid types: ${[...VALID_NODE_TYPES].join(', ')} (json-render / graph / web-artifact have dedicated endpoints).`,
1771
- }, 400);
1772
- }
1773
-
1774
- if (!VALID_NODE_TYPES.has(type)) {
1775
- if (type === 'json-render') {
1776
- return responseJson({
1777
- ok: false,
1778
- error: 'Node type "json-render" is created via POST /api/canvas/json-render. See /api/canvas/schema for the required spec shape.',
1779
- }, 400);
1780
- }
1781
- if (type === 'graph') {
1782
- return responseJson({
1783
- ok: false,
1784
- error: 'Node type "graph" is created via POST /api/canvas/graph. See /api/canvas/schema for graphType + data fields.',
1785
- }, 400);
1786
- }
1787
- if (type === 'web-artifact') {
1788
- return responseJson({
1789
- ok: false,
1790
- error: 'Node type "web-artifact" is created via POST /api/canvas/web-artifact with appTsx + title.',
1791
- }, 400);
1792
- }
1793
- if (type === 'html-primitive') {
1794
- return createCanvasHtmlPrimitiveNode(body);
1795
- }
1796
- return responseJson({ ok: false, error: `Invalid node type: "${type}".` }, 400);
1797
- }
1798
-
1799
- if (type === 'webpage') {
1800
- return createCanvasWebpageNode(body);
1801
- }
1802
-
1803
- if (type === 'html' && (typeof body.primitive === 'string' || typeof body.kind === 'string')) {
1804
- return createCanvasHtmlPrimitiveNode(body);
1805
- }
1806
-
1807
- if (type === 'group') {
1808
- const geometry = resolveCreateGeometry(body);
1809
- const childList = pickGroupChildIds(body);
1810
- if (childList.error) return responseJson({ ok: false, error: `Cannot create group: ${childList.error}` }, 400);
1811
- const childIds = childList.value ?? [];
1812
- const childError = validateGroupChildIds('', childIds);
1813
- if (childError) return responseJson({ ok: false, error: `Cannot create group: ${childError}` }, 400);
1814
- const { node } = createCanvasGroup({
1815
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1816
- childIds,
1817
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
1818
- ...geometry,
1819
- });
1820
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1821
- return responseJson(buildNodeResponse(node));
1822
- }
1823
-
1824
- const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1825
- ? body.data as Record<string, unknown>
1826
- : undefined;
1827
- if (type === 'html') {
1828
- if ('html' in body && typeof body.html !== 'string') {
1829
- return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
1830
- }
1831
- if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
1832
- return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
1833
- }
1834
- }
1835
- const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1836
- ? body.path
1837
- : body.content;
1838
- // For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
1839
- // so callers can POST { type: 'html', title, html, axCapabilities } without nesting
1840
- // under `data` (report #53 — transport parity with MCP canvas_add_html_node). A
1841
- // top-level value overrides the same key under `data` (mirrors the `html` precedence).
1842
- const topAxCapabilities = type === 'html' ? normalizeNodeAxCapabilities(body.axCapabilities) : null;
1843
- const htmlMergedData = type === 'html'
1844
- ? {
1845
- ...(extraData ?? {}),
1846
- ...(typeof body.html === 'string' ? { html: resolveHtmlContent(body.html) } : {}),
1847
- ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
1848
- ...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
1849
- ...(typeof body.description === 'string' ? { description: body.description } : {}),
1850
- ...(body.presentation === true ? { presentation: true } : {}),
1851
- ...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
1852
- ...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
1853
- ...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
1854
- ...(topAxCapabilities ? { axCapabilities: topAxCapabilities } : {}),
1855
- }
1856
- : extraData;
1857
- let added: ReturnType<typeof addCanvasNode>;
1858
- const geometry = resolveCreateGeometry(body);
1859
- try {
1860
- added = addCanvasNode({
1861
- type: type as CanvasNodeState['type'],
1862
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1863
- ...(typeof content === 'string' ? { content } : {}),
1864
- ...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
1865
- ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
1866
- ...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
1867
- ...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
1868
- ...(type === 'trace' && typeof body.duration === 'string' ? { duration: body.duration } : {}),
1869
- ...(type === 'trace' && typeof body.resultSummary === 'string' ? { resultSummary: body.resultSummary } : {}),
1870
- ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1871
- ...(body.strictSize === true ? { strictSize: true } : {}),
1872
- ...geometry,
1873
- defaultWidth: type === 'html'
1874
- ? 720
1875
- : type === 'markdown'
1876
- ? MARKDOWN_NODE_DEFAULT_SIZE.width
1877
- : type === 'mcp-app'
1878
- ? MCP_APP_NODE_DEFAULT_SIZE.width
1879
- : type === 'image'
1880
- ? IMAGE_NODE_DEFAULT_SIZE.width
1881
- : type === 'ledger'
1882
- ? LEDGER_NODE_DEFAULT_SIZE.width
1883
- : 360,
1884
- defaultHeight: type === 'html'
1885
- ? 640
1886
- : type === 'markdown'
1887
- ? MARKDOWN_NODE_DEFAULT_SIZE.height
1888
- : type === 'mcp-app'
1889
- ? MCP_APP_NODE_DEFAULT_SIZE.height
1890
- : type === 'image'
1891
- ? IMAGE_NODE_DEFAULT_SIZE.height
1892
- : type === 'ledger'
1893
- ? LEDGER_NODE_DEFAULT_SIZE.height
1894
- : 200,
1895
- fileMode: 'auto',
1896
- });
1897
- } catch (error) {
1898
- const message = error instanceof Error ? error.message : String(error);
1899
- return responseJson({ ok: false, error: message }, 400);
1900
- }
1901
- const { node, needsCodeGraphRecompute } = added;
1902
-
1903
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1904
- if (needsCodeGraphRecompute) {
1905
- scheduleCodeGraphRecompute(() => {
1906
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1907
- });
1908
- }
1909
- return responseJson(buildNodeResponse(node));
1910
- }
1911
-
1912
- function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response {
1913
- const rawKind = typeof body.primitive === 'string' ? body.primitive : body.kind;
1914
- if (typeof rawKind !== 'string' || !isHtmlPrimitiveKind(rawKind)) {
1915
- return responseJson({ ok: false, error: `Unknown HTML primitive: ${String(rawKind)}.` }, 400);
1916
- }
1917
- const data = isRecord(body.data) ? body.data : {};
1918
- let built: ReturnType<typeof buildHtmlPrimitive>;
1919
- try {
1920
- built = buildHtmlPrimitive({
1921
- kind: rawKind,
1922
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1923
- data,
1924
- });
1925
- } catch (error) {
1926
- return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1927
- }
1928
- const geometry = resolveCreateGeometry(body);
1929
- const { node } = addCanvasNode({
1930
- type: 'html',
1931
- title: built.title,
1932
- data: {
1933
- html: built.html,
1934
- htmlPrimitive: built.kind,
1935
- primitiveData: built.data,
1936
- description: built.summary,
1937
- agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
1938
- ...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
1939
- ...getHtmlPrimitiveSemanticMetadata(built.data),
1940
- },
1941
- ...(body.strictSize === true ? { strictSize: true } : {}),
1942
- ...geometry,
1943
- defaultWidth: built.defaultSize.width,
1944
- defaultHeight: built.defaultSize.height,
1945
- });
1946
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1947
- return responseJson({
1948
- ...buildNodeResponse(node),
1949
- primitive: {
1950
- kind: built.kind,
1951
- title: built.title,
1952
- htmlBytes: Buffer.byteLength(built.html, 'utf-8'),
1953
- defaultSize: built.defaultSize,
1954
- },
1955
- });
1956
- }
1957
-
1958
- // ── Group operations ─────────────────────────────────────────
1959
- async function handleCanvasCreateGroup(req: Request): Promise<Response> {
1960
- const body = await readJson(req);
1961
- const title = typeof body.title === 'string' ? body.title : 'Group';
1962
- const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
1963
- const color = typeof body.color === 'string' ? body.color : undefined;
1964
- const x = typeof body.x === 'number' ? body.x : undefined;
1965
- const y = typeof body.y === 'number' ? body.y : undefined;
1966
- const width = typeof body.width === 'number' ? body.width : undefined;
1967
- const height = typeof body.height === 'number' ? body.height : undefined;
1968
- const childLayout =
1969
- body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1970
- ? body.childLayout
1971
- : undefined;
1972
- if (childIds.length > 0) {
1973
- const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
1974
- if (missingChildIds.length > 0) {
1975
- return responseJson({
1976
- ok: false,
1977
- error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
1978
- }, 400);
1979
- }
1980
- }
1981
-
1982
- const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
1983
-
1984
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1985
- return responseJson(buildNodeResponse(node));
1986
- }
1987
-
1988
- async function handleCanvasGroupNodes(req: Request): Promise<Response> {
1989
- const body = await readJson(req);
1990
- const groupId = body.groupId as string;
1991
- const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
1992
- const childLayout =
1993
- body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1994
- ? body.childLayout
1995
- : undefined;
1996
- if (!groupId || childIds.length === 0) {
1997
- return responseJson({ ok: false, error: 'Missing groupId or childIds.' }, 400);
1998
- }
1999
- const { ok } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
2000
- if (!ok) return responseJson({ ok: false, error: 'Group not found or no valid children.' }, 400);
2001
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2002
- return responseJson({ ok: true, groupId });
2003
- }
2004
-
2005
- async function handleCanvasUngroupNodes(req: Request): Promise<Response> {
2006
- const body = await readJson(req);
2007
- const groupId = body.groupId as string;
2008
- if (!groupId) return responseJson({ ok: false, error: 'Missing groupId.' }, 400);
2009
- const { ok } = ungroupCanvasNodes(groupId);
2010
- if (!ok) return responseJson({ ok: false, error: 'Group not found or empty.' }, 400);
2011
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2012
- return responseJson({ ok: true, groupId });
2013
- }
2014
-
2015
- const VALID_EDGE_TYPES = new Set(['relation', 'depends-on', 'flow', 'references']);
2016
- const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
2017
-
2018
- async function handleCanvasAddEdge(req: Request): Promise<Response> {
2019
- const body = await readJson(req);
2020
- const type = body.type as string;
2021
- const style = typeof body.style === 'string' ? body.style : undefined;
2022
-
2023
- if (
2024
- !type ||
2025
- (!body.from && !body.fromSearch) ||
2026
- (!body.to && !body.toSearch)
2027
- ) {
2028
- return responseJson({ ok: false, error: 'Missing required fields: type plus from/fromSearch and to/toSearch.' }, 400);
2029
- }
2030
- if (!VALID_EDGE_TYPES.has(type)) {
2031
- return responseJson({ ok: false, error: `Invalid edge type: "${type}".` }, 400);
2032
- }
2033
- if (style && !VALID_EDGE_STYLES.has(style)) {
2034
- return responseJson({ ok: false, error: `Invalid edge style: "${style}". Use solid, dashed, or dotted.` }, 400);
2035
- }
2036
- try {
2037
- const result = addCanvasEdge({
2038
- ...(typeof body.from === 'string' ? { from: body.from } : {}),
2039
- ...(typeof body.to === 'string' ? { to: body.to } : {}),
2040
- ...(typeof body.fromSearch === 'string' ? { fromSearch: body.fromSearch } : {}),
2041
- ...(typeof body.toSearch === 'string' ? { toSearch: body.toSearch } : {}),
2042
- type: type as CanvasEdge['type'],
2043
- ...(body.label ? { label: String(body.label) } : {}),
2044
- ...(style ? { style: style as CanvasEdge['style'] } : {}),
2045
- ...(body.animated !== undefined ? { animated: Boolean(body.animated) } : {}),
2046
- });
2047
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2048
- return responseJson({ ok: true, ...result });
2049
- } catch (error) {
2050
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Duplicate or self-edge.' }, 400);
2051
- }
2052
- }
2053
-
2054
- async function handleCanvasRemoveEdge(req: Request): Promise<Response> {
2055
- const body = await readJson(req);
2056
- const edgeId = body.edge_id as string;
2057
- if (!edgeId) {
2058
- return responseJson({ ok: false, error: 'Missing edge_id.' }, 400);
2059
- }
2060
- const { removed } = removeCanvasEdge(edgeId);
2061
- if (!removed) {
2062
- return responseJson({ ok: false, error: `Edge "${edgeId}" not found.` }, 404);
2063
- }
2064
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2065
- return responseJson({ ok: true, removed: edgeId });
2066
- }
2067
-
2068
1506
  async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Promise<Response> {
2069
1507
  const existing = canvasState.getNode(nodeId);
2070
1508
  if (!existing || existing.type !== 'webpage') {
@@ -2087,513 +1525,28 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
2087
1525
  return responseJson(result, result.ok ? 200 : 400);
2088
1526
  }
2089
1527
 
2090
- // ── Individual node update (PATCH) ──────────────────────────
2091
- async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Response> {
2092
- const existing = canvasState.getNode(nodeId);
2093
- if (!existing) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
2094
- const body = await readJson(req);
2095
- if (existing.type === 'webpage' && body.refresh === true) {
2096
- return handleCanvasRefreshWebpageNode(nodeId, req);
2097
- }
2098
- const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
2099
- if (groupChildList.error) return responseJson({ ok: false, error: `Cannot update group: ${groupChildList.error}` }, 400);
2100
- const groupChildIds = groupChildList.value;
2101
- if (groupChildIds !== undefined) {
2102
- const childError = validateGroupChildIds(nodeId, groupChildIds);
2103
- if (childError) return responseJson({ ok: false, error: `Cannot update group: ${childError}` }, 400);
2104
- }
2105
- const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
2106
- if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
2107
- if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
2108
- if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
2109
- patch.dockPosition = body.dockPosition;
2110
- }
2111
- if (hasStructuredNodeUpdateFields(body)) {
2112
- try {
2113
- patch.data = buildStructuredNodeUpdate(existing, body).data;
2114
- } catch (error) {
2115
- return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
2116
- }
2117
- } else if (
2118
- body.title !== undefined ||
2119
- body.content !== undefined ||
2120
- body.data ||
2121
- typeof body.arrangeLocked === 'boolean' ||
2122
- typeof body.strictSize === 'boolean' ||
2123
- (existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
2124
- (existing.type === 'html' && (body.html !== undefined || body.axCapabilities !== undefined))
2125
- ) {
2126
- const data = { ...existing.data };
2127
- if (body.title !== undefined) {
2128
- data.title = String(body.title);
2129
- if (existing.type === 'webpage') {
2130
- data.titleSource = 'user';
2131
- }
2132
- }
2133
- if (body.content !== undefined) data.content = String(body.content);
2134
- if (typeof body.arrangeLocked === 'boolean') data.arrangeLocked = body.arrangeLocked;
2135
- if (typeof body.strictSize === 'boolean') data.strictSize = body.strictSize;
2136
- // Merge extra data fields (for status, context, ledger, trace nodes)
2137
- if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
2138
- Object.assign(data, body.data as Record<string, unknown>);
2139
- }
2140
- // Report #53: for html nodes, accept top-level `html` / `axCapabilities` on PATCH
2141
- // too (top-level overrides the `data.*` merge above — matches POST + MCP parity).
2142
- if (existing.type === 'html') {
2143
- if (body.html !== undefined) {
2144
- if (typeof body.html !== 'string') {
2145
- return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
2146
- }
2147
- data.html = resolveHtmlContent(body.html);
2148
- }
2149
- const patchAxCapabilities = normalizeNodeAxCapabilities(body.axCapabilities);
2150
- if (patchAxCapabilities) data.axCapabilities = patchAxCapabilities;
2151
- }
2152
- if (existing.type === 'webpage') {
2153
- const nextUrl = typeof body.url === 'string'
2154
- ? body.url
2155
- : typeof (body.data as Record<string, unknown> | undefined)?.url === 'string'
2156
- ? (body.data as Record<string, unknown>).url as string
2157
- : undefined;
2158
- if (typeof nextUrl === 'string' && nextUrl.trim().length > 0) {
2159
- try {
2160
- data.url = normalizeWebpageUrl(nextUrl);
2161
- } catch (error) {
2162
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
2163
- }
2164
- }
2165
- }
2166
- patch.data = existing.type === 'trace'
2167
- ? mergeTraceNodeDataFields(data, body)
2168
- : data;
2169
- }
2170
- const error = validateCanvasNodePatch({
2171
- ...(patch.position ? { position: patch.position as { x: number; y: number } } : {}),
2172
- ...(patch.size ? { size: patch.size as { width: number; height: number } } : {}),
2173
- });
2174
- if (error) return responseJson({ ok: false, error }, 400);
2175
- canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
2176
- if (groupChildIds !== undefined && !setGroupChildrenFromApi(nodeId, groupChildIds)) {
2177
- return responseJson({ ok: false, error: `Group "${nodeId}" not found.` }, 404);
2178
- }
2179
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2180
- const updated = canvasState.getNode(nodeId);
2181
- return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
2182
- }
2183
-
2184
- // ── Arrange nodes ───────────────────────────────────────────
2185
- async function handleCanvasArrange(req: Request): Promise<Response> {
2186
- const body = await readJson(req);
2187
- const layout = typeof body.layout === 'string' ? body.layout : 'grid';
2188
- if (!['grid', 'column', 'flow'].includes(layout)) {
2189
- return responseJson({ ok: false, error: `Invalid layout: "${layout}". Use: grid, column, flow` }, 400);
2190
- }
2191
- const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
2192
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2193
- const validation = validateCanvasLayout(canvasState.getLayout());
2194
- return responseJson({
2195
- ok: validation.ok,
2196
- arranged: result.arranged,
2197
- layout: result.layout,
2198
- ...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
2199
- });
2200
- }
1528
+ // handleCanvasBuildWebArtifact migrated to the operation registry
1529
+ // (plan-008 Wave 4): src/server/operations/ops/app.ts (webartifact.build).
2201
1530
 
2202
- // ── Focus on node ───────────────────────────────────────────
2203
- async function handleCanvasFocus(req: Request): Promise<Response> {
1531
+ async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2204
1532
  const body = await readJson(req);
2205
- const nodeId = body.id as string;
2206
- if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
2207
- const node = canvasState.getNode(nodeId);
2208
- if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
2209
- const noPan = body.noPan === true;
2210
- if (!noPan) {
2211
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
2212
- } else {
2213
- const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
2214
- canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
2215
- }
2216
- const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
2217
- broadcastWorkbenchEvent('ax-state-changed', {
2218
- focus,
1533
+ const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
1534
+ const next = canvasState.setTheme(theme);
1535
+ broadcastWorkbenchEvent('theme-changed', {
1536
+ theme: next,
2219
1537
  sessionId: primaryWorkbenchSessionId,
2220
1538
  timestamp: new Date().toISOString(),
2221
1539
  });
2222
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
2223
- if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
2224
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2225
- return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
1540
+ return responseJson({ ok: true, theme: next });
2226
1541
  }
2227
1542
 
2228
- async function handleCanvasFit(req: Request): Promise<Response> {
2229
- const body = await readJson(req);
2230
- const nodeIds = Array.isArray(body.nodeIds)
2231
- ? body.nodeIds.filter((id): id is string => typeof id === 'string')
2232
- : undefined;
2233
- const result = fitCanvasView({
2234
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
2235
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2236
- ...(typeof body.padding === 'number' ? { padding: body.padding } : {}),
2237
- ...(typeof body.maxScale === 'number' ? { maxScale: body.maxScale } : {}),
2238
- ...(nodeIds ? { nodeIds } : {}),
2239
- });
2240
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: result.viewport });
2241
- return responseJson(result);
2242
- }
2243
-
2244
- async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
2245
- const body = await readJson(req);
2246
- const title = typeof body.title === 'string' ? body.title.trim() : '';
2247
- const appTsx = typeof body.appTsx === 'string' ? body.appTsx : '';
2248
- if (!title || !appTsx) {
2249
- return responseJson({ ok: false, error: 'Missing required fields: title, appTsx.' }, 400);
2250
- }
2251
-
2252
- const files: Record<string, string> = {};
2253
- if (body.files && typeof body.files === 'object' && !Array.isArray(body.files)) {
2254
- for (const [pathKey, value] of Object.entries(body.files as Record<string, unknown>)) {
2255
- if (typeof value === 'string') files[pathKey] = value;
2256
- }
2257
- }
2258
-
2259
- try {
2260
- const result = await buildWebArtifactOnCanvas({
2261
- title,
2262
- appTsx,
2263
- ...(typeof body.indexCss === 'string' ? { indexCss: body.indexCss } : {}),
2264
- ...(typeof body.mainTsx === 'string' ? { mainTsx: body.mainTsx } : {}),
2265
- ...(typeof body.indexHtml === 'string' ? { indexHtml: body.indexHtml } : {}),
2266
- ...(Object.keys(files).length > 0 ? { files } : {}),
2267
- ...(typeof body.projectPath === 'string'
2268
- ? { projectPath: resolveWorkspacePath(body.projectPath, activeWorkspaceRoot) }
2269
- : {}),
2270
- ...(typeof body.outputPath === 'string'
2271
- ? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
2272
- : {}),
2273
- // Script-path overrides are honored only when contained inside the
2274
- // workspace (enforced by resolveTrustedScriptPath in
2275
- // executeWebArtifactBuild), so they cannot point at an arbitrary host
2276
- // script for bash execution.
2277
- ...(typeof body.initScriptPath === 'string'
2278
- ? { initScriptPath: body.initScriptPath }
2279
- : {}),
2280
- ...(typeof body.bundleScriptPath === 'string'
2281
- ? { bundleScriptPath: body.bundleScriptPath }
2282
- : {}),
2283
- ...(Array.isArray(body.deps)
2284
- ? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
2285
- : {}),
2286
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
2287
- ...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
2288
- });
2289
-
2290
- return responseJson({
2291
- ok: true,
2292
- path: result.filePath,
2293
- bytes: result.fileSize,
2294
- projectPath: result.projectPath,
2295
- openedInCanvas: result.openedInCanvas,
2296
- startedAt: result.startedAt,
2297
- completedAt: result.completedAt,
2298
- durationMs: result.durationMs,
2299
- timeoutMs: result.timeoutMs,
2300
- // `id` is the canvas node id alias used by every other add-style
2301
- // response. It is only present when a canvas node was actually
2302
- // created (i.e. openInCanvas was not explicitly disabled). When
2303
- // there is no canvas node, the alias is intentionally omitted so
2304
- // consumers can `'id' in response` to detect the build-only case.
2305
- ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
2306
- nodeId: result.nodeId,
2307
- url: result.url,
2308
- metadata: result.metadata,
2309
- logs: result.logs,
2310
- ...(body.includeLogs === true ? {
2311
- stdout: result.stdout,
2312
- stderr: result.stderr,
2313
- } : {}),
2314
- });
2315
- } catch (error) {
2316
- const message = error instanceof Error ? error.message : String(error);
2317
- return responseJson({ ok: false, error: message }, 400);
2318
- }
2319
- }
2320
-
2321
- function handleCanvasDescribeSchema(): Response {
2322
- return responseJson(describeCanvasSchema());
2323
- }
2324
-
2325
- async function handleCanvasValidateSpec(req: Request): Promise<Response> {
2326
- const body = await readJson(req);
2327
- const rawType = typeof body.type === 'string' ? body.type.trim() : '';
2328
- if (rawType !== 'json-render' && rawType !== 'graph' && rawType !== 'html-primitive') {
2329
- return responseJson({ ok: false, error: 'Validation type must be "json-render", "graph", or "html-primitive".' }, 400);
2330
- }
2331
-
2332
- try {
2333
- if (rawType === 'json-render') {
2334
- const rawSpec =
2335
- body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec)
2336
- ? body.spec
2337
- : body;
2338
- return responseJson(validateStructuredCanvasPayload({
2339
- type: 'json-render',
2340
- spec: rawSpec,
2341
- }));
2342
- }
2343
-
2344
- if (rawType === 'html-primitive') {
2345
- const kind = typeof body.kind === 'string'
2346
- ? body.kind
2347
- : typeof body.primitive === 'string'
2348
- ? body.primitive
2349
- : '';
2350
- const data = isRecord(body.data) ? body.data : {};
2351
- return responseJson(validateStructuredCanvasPayload({
2352
- type: 'html-primitive',
2353
- primitive: {
2354
- kind,
2355
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
2356
- data,
2357
- },
2358
- }));
2359
- }
2360
-
2361
- const data = parseGraphPayloadData(body.data);
2362
- if (!data) {
2363
- return responseJson({ ok: false, error: 'Graph validation requires a data array.' }, 400);
2364
- }
2365
-
2366
- const aggregate =
2367
- body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
2368
- ? body.aggregate
2369
- : undefined;
2370
-
2371
- return responseJson(validateStructuredCanvasPayload({
2372
- type: 'graph',
2373
- graph: {
2374
- title: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph',
2375
- graphType: typeof body.graphType === 'string'
2376
- ? body.graphType
2377
- : typeof body.typeName === 'string'
2378
- ? body.typeName
2379
- : 'line',
2380
- data,
2381
- ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
2382
- ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
2383
- ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
2384
- ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
2385
- ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
2386
- ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
2387
- ...(Array.isArray(body.metrics)
2388
- ? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
2389
- : {}),
2390
- ...(Array.isArray(body.series)
2391
- ? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
2392
- : {}),
2393
- ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
2394
- ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
2395
- ...(aggregate ? { aggregate } : {}),
2396
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
2397
- ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
2398
- ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
2399
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2400
- },
2401
- }));
2402
- } catch (error) {
2403
- const message = error instanceof Error ? error.message : String(error);
2404
- return responseJson({ ok: false, error: message, type: rawType }, 400);
2405
- }
2406
- }
2407
-
2408
- async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
2409
- const body = await readJson(req);
2410
- const title = typeof body.title === 'string' ? body.title.trim() : '';
2411
- const rawSpec =
2412
- body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
2413
- const geometry = resolveCreateGeometry(body);
2414
-
2415
- try {
2416
- const result = createCanvasJsonRenderNode({
2417
- ...(title ? { title } : {}),
2418
- spec: rawSpec,
2419
- ...(body.strictSize === true ? { strictSize: true } : {}),
2420
- ...geometry,
2421
- });
2422
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2423
- return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
2424
- } catch (error) {
2425
- const message = error instanceof Error ? error.message : String(error);
2426
- return responseJson({ ok: false, error: message }, 400);
2427
- }
2428
- }
2429
-
2430
- async function handleJsonRenderStream(req: Request): Promise<Response> {
2431
- const body = await readJson(req);
2432
- const patches = Array.isArray(body.patches) ? body.patches : [];
2433
- const done = body.done === true;
2434
- const geometry = resolveCreateGeometry(body);
2435
- try {
2436
- let nodeId = typeof body.nodeId === 'string' && body.nodeId ? body.nodeId : undefined;
2437
- let url = '';
2438
- if (!nodeId) {
2439
- const created = createCanvasStreamingJsonRenderNode({
2440
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
2441
- ...(body.strictSize === true ? { strictSize: true } : {}),
2442
- ...geometry,
2443
- });
2444
- nodeId = created.id;
2445
- url = created.url;
2446
- }
2447
- const result = appendCanvasJsonRenderStream(nodeId, patches, done);
2448
- if (!result.ok) return responseJson({ ok: false, error: result.error }, 400);
2449
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2450
- const node = canvasState.getNode(nodeId);
2451
- return responseJson({ id: nodeId, url: url || String(node?.data.url ?? ''), ...result });
2452
- } catch (error) {
2453
- const message = error instanceof Error ? error.message : String(error);
2454
- return responseJson({ ok: false, error: message }, 400);
2455
- }
2456
- }
2457
-
2458
- async function handleCanvasAddGraph(req: Request): Promise<Response> {
2459
- const body = await readJson(req);
2460
- const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
2461
- const graphType = typeof body.graphType === 'string' ? body.graphType : typeof body.type === 'string' ? body.type : 'line';
2462
- const data = parseGraphPayloadData(body.data);
2463
- if (!data) {
2464
- return responseJson({ ok: false, error: 'Missing required field: data.' }, 400);
2465
- }
2466
-
2467
- try {
2468
- const aggregate =
2469
- body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
2470
- ? body.aggregate
2471
- : undefined;
2472
- const metrics = Array.isArray(body.metrics)
2473
- ? body.metrics.filter((m: unknown): m is string => typeof m === 'string')
2474
- : null;
2475
- const series = Array.isArray(body.series)
2476
- ? body.series.filter((s: unknown): s is string => typeof s === 'string')
2477
- : null;
2478
- const position = getRecord(body.position);
2479
- const size = getRecord(body.size);
2480
- const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
2481
- const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
2482
- const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
2483
- // Node FRAME height. `body.height` is the CHART plot height (passed through as
2484
- // `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
2485
- // `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
2486
- // field, the natural thing a caller reaches for. (With content-fit the node grows
2487
- // to the chart anyway; this just removes the silent "height ignored" surprise.)
2488
- const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
2489
- ?? pickPositiveNumber(body, 'heightPx')
2490
- ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2491
- const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
2492
- const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
2493
- const colorBy =
2494
- body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
2495
- ? body.colorBy
2496
- : undefined;
2497
- const highlight =
2498
- typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
2499
- ? body.highlight
2500
- : undefined;
2501
- const sort =
2502
- body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
2503
- const result = createCanvasGraphNode({
2504
- title,
2505
- graphType,
2506
- data,
2507
- ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
2508
- ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
2509
- ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
2510
- ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
2511
- ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
2512
- ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
2513
- ...(metrics ? { metrics } : {}),
2514
- ...(series ? { series } : {}),
2515
- ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
2516
- ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
2517
- ...(aggregate ? { aggregate } : {}),
2518
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
2519
- ...(colorBy ? { colorBy } : {}),
2520
- ...(highlight !== undefined ? { highlight } : {}),
2521
- ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
2522
- ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
2523
- ...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
2524
- ...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
2525
- ...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
2526
- ...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
2527
- ...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
2528
- ...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
2529
- ...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
2530
- ...(sort ? { sort } : {}),
2531
- ...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
2532
- ...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
2533
- ...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
2534
- ...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
2535
- ...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
2536
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2537
- ...(showLegend !== undefined ? { showLegend } : {}),
2538
- ...(showLabels !== undefined ? { showLabels } : {}),
2539
- ...(body.strictSize === true ? { strictSize: true } : {}),
2540
- ...(x !== undefined ? { x } : {}),
2541
- ...(y !== undefined ? { y } : {}),
2542
- ...(width !== undefined ? { width } : {}),
2543
- ...(nodeHeight !== undefined ? { heightPx: nodeHeight } : {}),
2544
- });
2545
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2546
- return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
2547
- } catch (error) {
2548
- const message = error instanceof Error ? error.message : String(error);
2549
- return responseJson({ ok: false, error: message }, 400);
2550
- }
2551
- }
2552
-
2553
- async function handleCanvasBatch(req: Request): Promise<Response> {
2554
- // Accept both documented shapes: { operations: [...] } and a bare [...] array.
2555
- // Uses the array-preserving reader so the bare-array form isn't coerced to {}.
2556
- const body = await readJsonObjectOrArray(req);
2557
- const operations = Array.isArray(body)
2558
- ? body
2559
- : Array.isArray(body.operations) ? body.operations : [];
2560
- const normalized = operations
2561
- .filter((operation): operation is Record<string, unknown> => operation && typeof operation === 'object' && !Array.isArray(operation))
2562
- .map((operation) => ({
2563
- op: String(operation.op ?? ''),
2564
- ...(typeof operation.assign === 'string' ? { assign: operation.assign } : {}),
2565
- args: operation.args && typeof operation.args === 'object' && !Array.isArray(operation.args)
2566
- ? operation.args as Record<string, unknown>
2567
- : {},
2568
- }));
2569
- const result = await executeCanvasBatch(normalized);
2570
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2571
- return responseJson(result, result.ok ? 200 : 400);
2572
- }
2573
-
2574
- function handleCanvasValidate(): Response {
2575
- return responseJson(validateCanvasLayout(canvasState.getLayout()));
2576
- }
2577
-
2578
- async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2579
- const body = await readJson(req);
2580
- const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
2581
- const next = canvasState.setTheme(theme);
2582
- broadcastWorkbenchEvent('theme-changed', {
2583
- theme: next,
2584
- sessionId: primaryWorkbenchSessionId,
2585
- timestamp: new Date().toISOString(),
2586
- });
2587
- return responseJson({ ok: true, theme: next });
2588
- }
2589
-
2590
- async function handleJsonRenderView(url: URL): Promise<Response> {
2591
- const nodeId = url.searchParams.get('nodeId') ?? '';
2592
- if (!nodeId) return responseText('Missing nodeId', 400);
2593
- const node = canvasState.getNode(nodeId);
2594
- if (!node || (node.type !== 'json-render' && node.type !== 'graph')) {
2595
- return responseText('json-render node not found', 404);
2596
- }
1543
+ async function handleJsonRenderView(url: URL): Promise<Response> {
1544
+ const nodeId = url.searchParams.get('nodeId') ?? '';
1545
+ if (!nodeId) return responseText('Missing nodeId', 400);
1546
+ const node = canvasState.getNode(nodeId);
1547
+ if (!node || (node.type !== 'json-render' && node.type !== 'graph')) {
1548
+ return responseText('json-render node not found', 404);
1549
+ }
2597
1550
 
2598
1551
  const rawSpec = node.data.spec;
2599
1552
  if (!rawSpec || typeof rawSpec !== 'object') {
@@ -2660,13 +1613,6 @@ function responseText(text: string, status = 400): Response {
2660
1613
  });
2661
1614
  }
2662
1615
 
2663
- function parsePositiveIntegerParam(value: string | null): number | undefined {
2664
- if (!value) return undefined;
2665
- const parsed = Number(value);
2666
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
2667
- return Math.floor(parsed);
2668
- }
2669
-
2670
1616
  function handleArtifactView(url: URL): Response {
2671
1617
  const pathLike = url.searchParams.get('path') ?? '';
2672
1618
  const safePath = resolveWorkspaceArtifactPath(pathLike);
@@ -2855,229 +1801,6 @@ function handleRead(pathLike: string): Response {
2855
1801
  });
2856
1802
  }
2857
1803
 
2858
- function randomExtAppToolCallId(): string {
2859
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2860
- }
2861
-
2862
- function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
2863
- if (!node || node.type !== 'mcp-app') return null;
2864
- const sessionId = node.data.appSessionId;
2865
- return typeof sessionId === 'string' && sessionId.trim().length > 0 ? sessionId : null;
2866
- }
2867
-
2868
- function closeNodeAppSession(node: CanvasNodeState | undefined): void {
2869
- const sessionId = nodeAppSessionId(node);
2870
- if (sessionId) closeMcpAppSession(sessionId);
2871
- }
2872
-
2873
- function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
2874
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
2875
- const entries = Object.entries(value)
2876
- .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
2877
- .map(([key, text]) => [key, text.trim()] as const)
2878
- .filter(([, text]) => text.length > 0);
2879
- return entries.length > 0 ? Object.fromEntries(entries) : undefined;
2880
- }
2881
-
2882
- function parseExternalMcpTransportConfig(body: Record<string, unknown>): ExternalMcpTransportConfig | null {
2883
- const transport = body.transport;
2884
- if (!transport || typeof transport !== 'object' || Array.isArray(transport)) return null;
2885
- const transportRecord = transport as Record<string, unknown>;
2886
-
2887
- const type = typeof transportRecord.type === 'string' ? transportRecord.type : '';
2888
- if (type === 'http') {
2889
- const url = typeof transportRecord.url === 'string' ? transportRecord.url.trim() : '';
2890
- if (!url) return null;
2891
- const headers = normalizeStringRecord(transportRecord.headers);
2892
- return {
2893
- type: 'http',
2894
- url,
2895
- ...(headers ? { headers } : {}),
2896
- };
2897
- }
2898
-
2899
- if (type === 'stdio') {
2900
- const command = typeof transportRecord.command === 'string' ? transportRecord.command.trim() : '';
2901
- if (!command) return null;
2902
- const env = normalizeStringRecord(transportRecord.env);
2903
- return {
2904
- type: 'stdio',
2905
- command,
2906
- ...(Array.isArray(transportRecord.args)
2907
- ? { args: transportRecord.args.filter((value: unknown): value is string => typeof value === 'string') }
2908
- : {}),
2909
- ...(typeof transportRecord.cwd === 'string' && transportRecord.cwd.trim().length > 0 ? { cwd: transportRecord.cwd } : {}),
2910
- ...(env ? { env } : {}),
2911
- };
2912
- }
2913
-
2914
- return null;
2915
- }
2916
-
2917
- interface RunAndEmitOpenMcpAppParams {
2918
- transport: ExternalMcpTransportConfig;
2919
- toolName: string;
2920
- toolArguments?: Record<string, unknown>;
2921
- nodeId?: string;
2922
- serverName?: string;
2923
- title?: string;
2924
- x?: number;
2925
- y?: number;
2926
- width?: number;
2927
- height?: number;
2928
- timeoutMs?: number;
2929
- }
2930
-
2931
- async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise<Response> {
2932
- try {
2933
- const targetNode = params.nodeId ? canvasState.getNode(params.nodeId) : undefined;
2934
- if (params.nodeId && !targetNode) {
2935
- return responseJson({ ok: false, error: `Node "${params.nodeId}" not found.` }, 404);
2936
- }
2937
- if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
2938
- return responseJson({ ok: false, error: `Node "${params.nodeId}" is not an external app node.` }, 400);
2939
- }
2940
-
2941
- const opened = await openMcpApp({
2942
- transport: params.transport,
2943
- toolName: params.toolName,
2944
- ...(params.toolArguments ? { toolArguments: params.toolArguments } : {}),
2945
- ...(params.serverName ? { serverName: params.serverName } : {}),
2946
- ...(typeof params.timeoutMs === 'number' ? { timeoutMs: params.timeoutMs } : {}),
2947
- });
2948
-
2949
- const toolCallId = randomExtAppToolCallId();
2950
- if (params.nodeId) closeNodeAppSession(targetNode);
2951
- const nodeIdSeed = params.nodeId ?? (toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`);
2952
- const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
2953
- ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
2954
- : opened.toolResult;
2955
- const nodeTitle = params.title
2956
- ?? (typeof targetNode?.data.title === 'string' ? targetNode.data.title : undefined)
2957
- ?? opened.tool.title
2958
- ?? opened.tool.name;
2959
-
2960
- emitPrimaryWorkbenchEvent('ext-app-open', {
2961
- toolCallId,
2962
- nodeId: nodeIdSeed,
2963
- title: nodeTitle,
2964
- html: opened.html,
2965
- toolInput: opened.toolInput,
2966
- serverName: opened.serverName,
2967
- toolName: opened.toolName,
2968
- appSessionId: opened.sessionId,
2969
- transportConfig: params.transport,
2970
- resourceUri: opened.resourceUri,
2971
- toolDefinition: opened.tool,
2972
- sessionStatus: 'ready',
2973
- sessionError: null,
2974
- ...(opened.resourceMeta ? { resourceMeta: opened.resourceMeta } : {}),
2975
- ...(typeof params.x === 'number' ? { x: params.x } : {}),
2976
- ...(typeof params.y === 'number' ? { y: params.y } : {}),
2977
- ...(typeof params.width === 'number' ? { width: params.width } : {}),
2978
- ...(typeof params.height === 'number' ? { height: params.height } : {}),
2979
- });
2980
- emitPrimaryWorkbenchEvent('ext-app-result', {
2981
- toolCallId,
2982
- nodeId: nodeIdSeed,
2983
- serverName: opened.serverName,
2984
- toolName: opened.toolName,
2985
- success: toolResult.isError !== true,
2986
- result: toolResult,
2987
- });
2988
- const nodeId = params.nodeId ?? findCanvasExtAppNodeId(toolCallId);
2989
-
2990
- return responseJson({
2991
- ok: true,
2992
- ...(nodeId ? { id: nodeId } : {}),
2993
- nodeId,
2994
- toolCallId,
2995
- sessionId: opened.sessionId,
2996
- resourceUri: opened.resourceUri,
2997
- serverName: opened.serverName,
2998
- toolName: opened.toolName,
2999
- });
3000
- } catch (error) {
3001
- return responseJson({
3002
- ok: false,
3003
- error: error instanceof Error ? error.message : String(error),
3004
- }, 400);
3005
- }
3006
- }
3007
-
3008
- async function handleCanvasOpenMcpApp(req: Request): Promise<Response> {
3009
- const body = await readJson(req);
3010
- const transport = parseExternalMcpTransportConfig(body);
3011
- const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
3012
- if (!transport || !toolName) {
3013
- return responseJson({ ok: false, error: 'Missing valid transport or toolName.' }, 400);
3014
- }
3015
-
3016
- const toolArguments =
3017
- body.toolArguments && typeof body.toolArguments === 'object' && !Array.isArray(body.toolArguments)
3018
- ? body.toolArguments as Record<string, unknown>
3019
- : undefined;
3020
-
3021
- const requestedTitle = typeof body.title === 'string' && body.title.trim().length > 0
3022
- ? body.title.trim()
3023
- : undefined;
3024
- const requestedServerName = typeof body.serverName === 'string' && body.serverName.trim().length > 0
3025
- ? body.serverName.trim()
3026
- : undefined;
3027
- const requestedNodeId = typeof body.nodeId === 'string' && body.nodeId.trim().length > 0
3028
- ? body.nodeId.trim()
3029
- : undefined;
3030
-
3031
- return runAndEmitOpenMcpApp({
3032
- transport,
3033
- toolName,
3034
- ...(toolArguments ? { toolArguments } : {}),
3035
- ...(requestedNodeId ? { nodeId: requestedNodeId } : {}),
3036
- ...(requestedServerName ? { serverName: requestedServerName } : {}),
3037
- ...(requestedTitle ? { title: requestedTitle } : {}),
3038
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
3039
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
3040
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
3041
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
3042
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
3043
- });
3044
- }
3045
-
3046
- async function handleCanvasAddDiagram(req: Request): Promise<Response> {
3047
- const body = await readJson(req);
3048
- let built;
3049
- try {
3050
- built = buildExcalidrawOpenMcpAppInput({
3051
- elements: body.elements,
3052
- ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
3053
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
3054
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
3055
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
3056
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
3057
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
3058
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
3059
- });
3060
- } catch (error) {
3061
- return responseJson({
3062
- ok: false,
3063
- error: error instanceof Error ? error.message : String(error),
3064
- }, 400);
3065
- }
3066
- return runAndEmitOpenMcpApp({
3067
- transport: built.transport,
3068
- toolName: built.toolName,
3069
- toolArguments: built.toolArguments,
3070
- serverName: built.serverName,
3071
- ...(built.nodeId ? { nodeId: built.nodeId } : {}),
3072
- ...(built.title ? { title: built.title } : {}),
3073
- ...(typeof built.x === 'number' ? { x: built.x } : {}),
3074
- ...(typeof built.y === 'number' ? { y: built.y } : {}),
3075
- ...(typeof built.width === 'number' ? { width: built.width } : {}),
3076
- ...(typeof built.height === 'number' ? { height: built.height } : {}),
3077
- ...(typeof built.timeoutMs === 'number' ? { timeoutMs: built.timeoutMs } : {}),
3078
- });
3079
- }
3080
-
3081
1804
  async function handleExtAppCallTool(req: Request): Promise<Response> {
3082
1805
  const body = await readJson(req);
3083
1806
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
@@ -3279,120 +2002,15 @@ function handleWorkbenchState(): Response {
3279
2002
  });
3280
2003
  }
3281
2004
 
3282
- function parseCanvasAutomationWebViewRequestBody(
3283
- body: Record<string, unknown>,
3284
- ): CanvasAutomationWebViewOptions {
3285
- const backendValue = typeof body.backend === 'string' ? body.backend.trim() : '';
3286
- const backend =
3287
- backendValue === 'chrome' || backendValue === 'webkit'
3288
- ? backendValue
3289
- : undefined;
3290
-
3291
- const width = typeof body.width === 'number' ? body.width : undefined;
3292
- const height = typeof body.height === 'number' ? body.height : undefined;
3293
- const chromePath = typeof body.chromePath === 'string' ? body.chromePath : undefined;
3294
- const dataStoreDir = typeof body.dataStoreDir === 'string' ? body.dataStoreDir : undefined;
3295
- const chromeArgv = Array.isArray(body.chromeArgv)
3296
- ? body.chromeArgv.filter((value): value is string => typeof value === 'string')
3297
- : undefined;
3298
-
3299
- return {
3300
- ...(backend ? { backend } : {}),
3301
- ...(width !== undefined ? { width } : {}),
3302
- ...(height !== undefined ? { height } : {}),
3303
- ...(chromePath ? { chromePath } : {}),
3304
- ...(chromeArgv ? { chromeArgv } : {}),
3305
- ...(dataStoreDir ? { dataStoreDir } : {}),
3306
- };
3307
- }
3308
-
2005
+ // Webview status / start / stop / evaluate / resize HTTP routes migrated to the
2006
+ // operation registry (plan-008 Wave 3): src/server/operations/ops/webview.ts,
2007
+ // dispatched in the fetch handler. The screenshot route + handler below stay
2008
+ // hand-written (binary response). `currentWorkbenchUrl` is still used by the
2009
+ // injected webview runner's start closure (see setWebviewRunner above).
3309
2010
  function currentWorkbenchUrl(): string | null {
3310
2011
  return server && typeof server.port === 'number' ? `${loopbackBaseUrl(server.port)}/workbench` : null;
3311
2012
  }
3312
2013
 
3313
- function handleWorkbenchWebViewStatus(): Response {
3314
- return responseJson(getCanvasAutomationWebViewStatus());
3315
- }
3316
-
3317
- async function handleWorkbenchWebViewStart(req: Request): Promise<Response> {
3318
- const url = currentWorkbenchUrl();
3319
- if (!url) {
3320
- return responseJson({ ok: false, error: 'Canvas server is not running.' }, 503);
3321
- }
3322
-
3323
- const body = await readJson(req);
3324
- const options = parseCanvasAutomationWebViewRequestBody(body);
3325
-
3326
- try {
3327
- const webview = await startCanvasAutomationWebView(url, options);
3328
- return responseJson({ ok: true, webview });
3329
- } catch (error) {
3330
- const message = error instanceof Error ? error.message : String(error);
3331
- const status = hasCanvasAutomationWebViewSupport() ? 500 : 501;
3332
- return responseJson({
3333
- ok: false,
3334
- error: message,
3335
- webview: getCanvasAutomationWebViewStatus(),
3336
- }, status);
3337
- }
3338
- }
3339
-
3340
- async function handleWorkbenchWebViewStop(): Promise<Response> {
3341
- try {
3342
- const stopped = await stopCanvasAutomationWebView();
3343
- return responseJson({
3344
- ok: true,
3345
- stopped,
3346
- webview: getCanvasAutomationWebViewStatus(),
3347
- });
3348
- } catch (error) {
3349
- const message = error instanceof Error ? error.message : String(error);
3350
- return responseJson({
3351
- ok: false,
3352
- error: message,
3353
- webview: getCanvasAutomationWebViewStatus(),
3354
- }, 500);
3355
- }
3356
- }
3357
-
3358
- async function handleWorkbenchWebViewEvaluate(req: Request): Promise<Response> {
3359
- const body = await readJson(req);
3360
- const expression = typeof body.expression === 'string' ? body.expression.trim() : '';
3361
- const script = typeof body.script === 'string' ? body.script.trim() : '';
3362
- if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
3363
- return responseJson({
3364
- ok: false,
3365
- error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an async IIFE).',
3366
- }, 400);
3367
- }
3368
- const source = script ? wrapCanvasAutomationScript(script) : expression;
3369
-
3370
- try {
3371
- const value = await evaluateCanvasAutomationWebView(source);
3372
- return responseJson({ ok: true, value });
3373
- } catch (error) {
3374
- const message = error instanceof Error ? error.message : String(error);
3375
- return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
3376
- }
3377
- }
3378
-
3379
- async function handleWorkbenchWebViewResize(req: Request): Promise<Response> {
3380
- const body = await readJson(req);
3381
- const width = typeof body.width === 'number' ? body.width : NaN;
3382
- const height = typeof body.height === 'number' ? body.height : NaN;
3383
- if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
3384
- return responseJson({ ok: false, error: 'Missing required positive numeric fields: width, height.' }, 400);
3385
- }
3386
-
3387
- try {
3388
- const webview = await resizeCanvasAutomationWebView(width, height);
3389
- return responseJson({ ok: true, webview });
3390
- } catch (error) {
3391
- const message = error instanceof Error ? error.message : String(error);
3392
- return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
3393
- }
3394
- }
3395
-
3396
2014
  async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
3397
2015
  const body = await readJson(req);
3398
2016
  const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
@@ -3866,43 +2484,6 @@ async function handleCanvasPrompt(req: Request): Promise<Response> {
3866
2484
  return responseJson({ ok: true, nodeId });
3867
2485
  }
3868
2486
 
3869
- async function handleSnapshotSave(req: Request): Promise<Response> {
3870
- const body = await readJson(req);
3871
- const name = typeof body.name === 'string' ? body.name.trim() : '';
3872
- if (!name) return responseText('Missing snapshot name', 400);
3873
- const snapshot = saveCanvasSnapshot(name);
3874
- if (!snapshot) return responseText('Failed to save snapshot', 500);
3875
- return responseJson({ ok: true, id: snapshot.id, snapshot });
3876
- }
3877
-
3878
- async function handleSnapshotGc(req: Request): Promise<Response> {
3879
- const body = await readJson(req);
3880
- const keepValue = body.keep;
3881
- const keep = typeof keepValue === 'number'
3882
- ? keepValue
3883
- : typeof keepValue === 'string'
3884
- ? Number(keepValue)
3885
- : undefined;
3886
- const dryRun = body.dryRun === true || body['dry-run'] === true;
3887
- return responseJson(gcCanvasSnapshots({ keep, dryRun }));
3888
- }
3889
-
3890
- async function handleContextPinsUpdate(req: Request): Promise<Response> {
3891
- const body = await readJson(req);
3892
- const MAX_PINS = 20;
3893
- const nodeIds = Array.isArray(body.nodeIds)
3894
- ? (body.nodeIds.filter((id: unknown) => typeof id === 'string') as string[]).slice(0, MAX_PINS)
3895
- : [];
3896
- const result = setCanvasContextPins(nodeIds, 'set');
3897
- broadcastWorkbenchEvent('context-pins-changed', {
3898
- count: result.count,
3899
- nodeIds: result.nodeIds,
3900
- sessionId: primaryWorkbenchSessionId,
3901
- timestamp: new Date().toISOString(),
3902
- });
3903
- return responseJson({ ok: true, count: result.count });
3904
- }
3905
-
3906
2487
  function handleGetPinnedContext(): Response {
3907
2488
  const pinnedIds = Array.from(canvasState.contextPinnedNodeIds);
3908
2489
  const preamble = pinnedIds.length > 0 ? buildSelectionContextPreamble(pinnedIds) : '';
@@ -3936,10 +2517,6 @@ function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
3936
2517
  : fallback;
3937
2518
  }
3938
2519
 
3939
- function handleGetAxState(): Response {
3940
- return responseJson({ ok: true, state: canvasState.getAxState() });
3941
- }
3942
-
3943
2520
  function handleGetAxContext(url: URL): Response {
3944
2521
  // Optional ?consumer= filters the compact `delivery` lead block (loop-safe — a
3945
2522
  // consumer never sees steering/activity it originated), so a host adapter can
@@ -3948,12 +2525,6 @@ function handleGetAxContext(url: URL): Response {
3948
2525
  return responseJson(buildCanvasAxContext(consumer));
3949
2526
  }
3950
2527
 
3951
- // Clamp ?waitMs= to [0, AX_WAIT_MAX_MS]. 0 (or absent/NaN) = a plain single read.
3952
- function parseAxWaitMs(url: URL): number {
3953
- const raw = Number(url.searchParams.get('waitMs') ?? '');
3954
- return Number.isFinite(raw) && raw > 0 ? Math.min(raw, AX_WAIT_MAX_MS) : 0;
3955
- }
3956
-
3957
2528
  function isReviewSeverity(v: unknown): v is PmxAxReviewSeverity {
3958
2529
  return v === 'info' || v === 'warning' || v === 'error';
3959
2530
  }
@@ -4030,43 +2601,10 @@ async function handleAxActivityIngest(req: Request): Promise<Response> {
4030
2601
  return responseJson({ ok: true, ...result });
4031
2602
  }
4032
2603
 
4033
- // Report primitive D: single-item read of a gate, with optional ?waitMs= long-poll
4034
- // that resolves when the human resolves it in the browser (gates that actually gate).
4035
- async function handleAxApprovalGet(url: URL, id: string, req: Request): Promise<Response> {
4036
- const waitMs = parseAxWaitMs(url);
4037
- const { value, pending } = await waitForAxResolution({
4038
- read: () => canvasState.getApproval(id),
4039
- isResolved: (g) => g.status !== 'pending',
4040
- timeoutMs: waitMs,
4041
- signal: req.signal,
4042
- });
4043
- if (!value) return responseJson({ ok: false, error: 'approval gate not found.' }, 404);
4044
- return responseJson({ ok: true, approvalGate: value, pending });
4045
- }
4046
-
4047
- async function handleAxElicitationGet(url: URL, id: string, req: Request): Promise<Response> {
4048
- const waitMs = parseAxWaitMs(url);
4049
- const { value, pending } = await waitForAxResolution({
4050
- read: () => canvasState.getElicitation(id),
4051
- isResolved: (e) => e.status !== 'pending',
4052
- timeoutMs: waitMs,
4053
- signal: req.signal,
4054
- });
4055
- if (!value) return responseJson({ ok: false, error: 'elicitation not found.' }, 404);
4056
- return responseJson({ ok: true, elicitation: value, pending });
4057
- }
4058
-
4059
- async function handleAxModeGet(url: URL, id: string, req: Request): Promise<Response> {
4060
- const waitMs = parseAxWaitMs(url);
4061
- const { value, pending } = await waitForAxResolution({
4062
- read: () => canvasState.getModeRequest(id),
4063
- isResolved: (m) => m.status !== 'pending',
4064
- timeoutMs: waitMs,
4065
- signal: req.signal,
4066
- });
4067
- if (!value) return responseJson({ ok: false, error: 'mode request not found.' }, 404);
4068
- return responseJson({ ok: true, modeRequest: value, pending });
4069
- }
2604
+ // Report primitive D single-item gate reads (GET /api/canvas/ax/{approval,
2605
+ // elicitation,mode}/:id) with the optional ?waitMs= long-poll migrated to the
2606
+ // operation registry (plan-007 Slice B wave 4):
2607
+ // src/server/operations/ops/ax-await.ts.
4070
2608
 
4071
2609
  // Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
4072
2610
  // The client fetches this and pushes it to surfaces over the ax-update channel.
@@ -4113,136 +2651,34 @@ async function handleAxInteraction(req: Request): Promise<Response> {
4113
2651
  return responseJson(result, result.ok ? 200 : result.status);
4114
2652
  }
4115
2653
 
4116
- function handleAxDeliveryPending(url: URL): Response {
4117
- const consumer = url.searchParams.get('consumer') ?? undefined;
4118
- const limitRaw = Number(url.searchParams.get('limit') ?? '');
4119
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
4120
- const pending = canvasState.getPendingSteering({
4121
- ...(consumer ? { consumer } : {}),
4122
- ...(limit ? { limit } : {}),
4123
- });
4124
- return responseJson({ ok: true, pending });
4125
- }
4126
-
4127
- function handleAxDeliveryMark(id: string): Response {
4128
- const delivered = canvasState.markSteeringDelivered(id);
4129
- if (delivered) {
4130
- broadcastWorkbenchEvent('ax-event-created', {
4131
- steeringDelivered: id,
4132
- sessionId: primaryWorkbenchSessionId,
4133
- timestamp: new Date().toISOString(),
4134
- });
4135
- }
4136
- return responseJson({ ok: true, delivered });
4137
- }
2654
+ // handleAxDeliveryPending / handleAxDeliveryMark migrated to the operation
2655
+ // registry (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
4138
2656
 
4139
2657
  function handleAxElicitationList(): Response {
4140
2658
  return responseJson({ ok: true, elicitations: canvasState.getElicitations() });
4141
2659
  }
4142
2660
 
4143
- async function handleAxElicitationRequest(req: Request): Promise<Response> {
4144
- const body = await readJson(req);
4145
- if (typeof body.prompt !== 'string' || !body.prompt.trim()) {
4146
- return responseJson({ ok: false, error: 'elicitation requires a prompt.' }, 400);
4147
- }
4148
- const elicitation = canvasState.requestElicitation(
4149
- {
4150
- prompt: body.prompt,
4151
- ...(Array.isArray(body.fields) ? { fields: body.fields.filter((f: unknown): f is string => typeof f === 'string') } : {}),
4152
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4153
- },
4154
- { source: normalizeAxSource(body.source, 'api') },
4155
- );
4156
- broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4157
- return responseJson({ ok: true, elicitation });
4158
- }
4159
-
4160
- async function handleAxElicitationRespond(req: Request, id: string): Promise<Response> {
4161
- const body = await readJson(req);
4162
- const response = isRecord(body.response) ? body.response : {};
4163
- const elicitation = canvasState.respondElicitation(id, response, { source: normalizeAxSource(body.source, 'api') });
4164
- if (!elicitation) return responseJson({ ok: false, error: 'elicitation not found or already answered.' }, 404);
4165
- broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4166
- return responseJson({ ok: true, elicitation });
4167
- }
2661
+ // handleAxElicitationRequest / handleAxElicitationRespond migrated to the
2662
+ // operation registry (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4168
2663
 
4169
2664
  function handleAxModeList(): Response {
4170
2665
  return responseJson({ ok: true, modeRequests: canvasState.getModeRequests() });
4171
2666
  }
4172
2667
 
4173
- async function handleAxModeRequest(req: Request): Promise<Response> {
4174
- const body = await readJson(req);
4175
- if (body.mode !== 'plan' && body.mode !== 'execute' && body.mode !== 'autonomous') {
4176
- return responseJson({ ok: false, error: 'mode request requires mode plan|execute|autonomous.' }, 400);
4177
- }
4178
- const modeRequest = canvasState.requestMode(
4179
- {
4180
- mode: body.mode,
4181
- ...(typeof body.reason === 'string' ? { reason: body.reason } : {}),
4182
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4183
- },
4184
- { source: normalizeAxSource(body.source, 'api') },
4185
- );
4186
- broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4187
- return responseJson({ ok: true, modeRequest });
4188
- }
4189
-
4190
- async function handleAxModeResolve(req: Request, id: string): Promise<Response> {
4191
- const body = await readJson(req);
4192
- if (body.decision !== 'approved' && body.decision !== 'rejected') {
4193
- return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
4194
- }
4195
- const modeRequest = canvasState.resolveModeRequest(id, body.decision, {
4196
- ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
4197
- source: normalizeAxSource(body.source, 'api'),
4198
- });
4199
- if (!modeRequest) return responseJson({ ok: false, error: 'mode request not found or already resolved.' }, 404);
4200
- broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4201
- return responseJson({ ok: true, modeRequest });
4202
- }
2668
+ // handleAxModeRequest / handleAxModeResolve migrated to the operation registry
2669
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4203
2670
 
4204
2671
  function handleAxCommandList(): Response {
4205
2672
  return responseJson({ ok: true, commands: canvasState.getCommandRegistry() });
4206
2673
  }
4207
2674
 
4208
- async function handleAxCommandInvoke(req: Request): Promise<Response> {
4209
- const body = await readJson(req);
4210
- if (typeof body.name !== 'string') {
4211
- return responseJson({ ok: false, error: 'command requires a name.' }, 400);
4212
- }
4213
- const event = canvasState.invokeCommand(body.name, isRecord(body.args) ? body.args : null, { source: normalizeAxSource(body.source, 'api') });
4214
- if (!event) return responseJson({ ok: false, error: `Unknown command "${body.name}".` }, 400);
4215
- broadcastWorkbenchEvent('ax-event-created', { event, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4216
- return responseJson({ ok: true, event });
4217
- }
2675
+ // handleAxCommandInvoke migrated to the operation registry (plan-007 Slice B
2676
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
4218
2677
 
4219
2678
  function handleAxPolicyGet(): Response {
4220
2679
  return responseJson({ ok: true, policy: canvasState.getPolicy() });
4221
2680
  }
4222
2681
 
4223
- async function handleAxPolicySet(req: Request): Promise<Response> {
4224
- const body = await readJson(req);
4225
- const patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> } = {};
4226
- if (isRecord(body.tools)) patch.tools = body.tools as Partial<PmxAxPolicy['tools']>;
4227
- if (isRecord(body.prompt)) patch.prompt = body.prompt as Partial<PmxAxPolicy['prompt']>;
4228
- const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(body.source, 'api') });
4229
- broadcastWorkbenchEvent('ax-state-changed', { policy, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4230
- return responseJson({ ok: true, policy });
4231
- }
4232
-
4233
- async function handleAxFocusUpdate(req: Request): Promise<Response> {
4234
- const body = await readJson(req);
4235
- const nodeIds = normalizeAxNodeIds(body.nodeIds);
4236
- const source = normalizeAxSource(body.source, 'api');
4237
- const focus = canvasState.setAxFocus(nodeIds, { source });
4238
- broadcastWorkbenchEvent('ax-state-changed', {
4239
- focus,
4240
- sessionId: primaryWorkbenchSessionId,
4241
- timestamp: new Date().toISOString(),
4242
- });
4243
- return responseJson({ ok: true, focus });
4244
- }
4245
-
4246
2682
  async function handleAxStatePatch(req: Request): Promise<Response> {
4247
2683
  const body = await readJson(req);
4248
2684
  if (!body.focus || typeof body.focus !== 'object' || Array.isArray(body.focus)) {
@@ -4260,52 +2696,8 @@ async function handleAxStatePatch(req: Request): Promise<Response> {
4260
2696
  return responseJson({ ok: true, state: canvasState.getAxState() });
4261
2697
  }
4262
2698
 
4263
- async function handleAxEventAdd(req: Request): Promise<Response> {
4264
- const body = await readJson(req);
4265
- if (!isAxEventKind(body.kind) || typeof body.summary !== 'string') {
4266
- return responseJson({ ok: false, error: 'event requires kind and summary.' }, 400);
4267
- }
4268
- const event = canvasState.recordAxEvent(
4269
- {
4270
- kind: body.kind,
4271
- summary: body.summary,
4272
- detail: typeof body.detail === 'string' ? body.detail : null,
4273
- nodeIds: normalizeAxNodeIds(body.nodeIds),
4274
- data: isRecord(body.data) ? body.data : null,
4275
- },
4276
- { source: normalizeAxSource(body.source, 'api') },
4277
- );
4278
- broadcastWorkbenchEvent('ax-event-created', {
4279
- event,
4280
- sessionId: primaryWorkbenchSessionId,
4281
- timestamp: new Date().toISOString(),
4282
- });
4283
- return responseJson({ ok: true, event });
4284
- }
4285
-
4286
- async function handleAxSteer(req: Request): Promise<Response> {
4287
- const body = await readJson(req);
4288
- if (typeof body.message !== 'string' || !body.message.trim()) {
4289
- return responseJson({ ok: false, error: 'steer requires a non-empty message.' }, 400);
4290
- }
4291
- const steering = canvasState.recordSteeringMessage(body.message, {
4292
- source: normalizeAxSource(body.source, 'api'),
4293
- });
4294
- broadcastWorkbenchEvent('ax-event-created', {
4295
- steering,
4296
- sessionId: primaryWorkbenchSessionId,
4297
- timestamp: new Date().toISOString(),
4298
- });
4299
- return responseJson({ ok: true, steering });
4300
- }
4301
-
4302
- function handleAxTimelineGet(url: URL): Response {
4303
- const limit = Number(url.searchParams.get('limit') ?? '');
4304
- return responseJson({
4305
- ok: true,
4306
- ...canvasState.getAxTimeline(Number.isFinite(limit) && limit > 0 ? { limit } : {}),
4307
- });
4308
- }
2699
+ // handleAxEventAdd / handleAxSteer / handleAxTimelineGet migrated to the
2700
+ // operation registry (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
4309
2701
 
4310
2702
  const AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
4311
2703
 
@@ -4319,234 +2711,33 @@ function handleAxWorkList(): Response {
4319
2711
  return responseJson({ ok: true, workItems: canvasState.getWorkItems() });
4320
2712
  }
4321
2713
 
4322
- async function handleAxWorkAdd(req: Request): Promise<Response> {
4323
- const body = await readJson(req);
4324
- if (typeof body.title !== 'string' || !body.title.trim()) {
4325
- return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
4326
- }
4327
- // Report #56: reject an unknown status (e.g. "in_progress") instead of silently
4328
- // dropping it — the accepted tokens use hyphens.
4329
- if (body.status !== undefined && !normalizeAxWorkItemStatus(body.status)) {
4330
- return responseJson({ ok: false, error: `invalid work item status "${String(body.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.` }, 400);
4331
- }
4332
- const status = normalizeAxWorkItemStatus(body.status);
4333
- const workItem = canvasState.addWorkItem(
4334
- {
4335
- title: body.title,
4336
- ...(status ? { status } : {}),
4337
- ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
4338
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4339
- },
4340
- { source: normalizeAxSource(body.source, 'api') },
4341
- );
4342
- broadcastWorkbenchEvent('ax-state-changed', {
4343
- workItem,
4344
- sessionId: primaryWorkbenchSessionId,
4345
- timestamp: new Date().toISOString(),
4346
- });
4347
- return responseJson({ ok: true, workItem });
4348
- }
4349
-
4350
- async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
4351
- const body = await readJson(req);
4352
- // Report #56: reject an unknown status instead of returning ok:true + no-op.
4353
- if (body.status !== undefined && !normalizeAxWorkItemStatus(body.status)) {
4354
- return responseJson({ ok: false, error: `invalid work item status "${String(body.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.` }, 400);
4355
- }
4356
- const status = normalizeAxWorkItemStatus(body.status);
4357
- const workItem = canvasState.updateWorkItem(
4358
- id,
4359
- {
4360
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
4361
- ...(status ? { status } : {}),
4362
- ...(typeof body.detail === 'string' || body.detail === null ? { detail: body.detail as string | null } : {}),
4363
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4364
- },
4365
- { source: normalizeAxSource(body.source, 'api') },
4366
- );
4367
- if (!workItem) return responseJson({ ok: false, error: 'work item not found.' }, 404);
4368
- broadcastWorkbenchEvent('ax-state-changed', {
4369
- workItem,
4370
- sessionId: primaryWorkbenchSessionId,
4371
- timestamp: new Date().toISOString(),
4372
- });
4373
- return responseJson({ ok: true, workItem });
4374
- }
2714
+ // handleAxWorkAdd / handleAxWorkUpdate migrated to the operation registry
2715
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4375
2716
 
4376
2717
  function handleAxApprovalList(): Response {
4377
2718
  return responseJson({ ok: true, approvalGates: canvasState.getApprovalGates() });
4378
2719
  }
4379
2720
 
4380
- async function handleAxApprovalRequest(req: Request): Promise<Response> {
4381
- const body = await readJson(req);
4382
- if (typeof body.title !== 'string' || !body.title.trim()) {
4383
- return responseJson({ ok: false, error: 'approval request requires a title.' }, 400);
4384
- }
4385
- const approvalGate = canvasState.requestApproval(
4386
- {
4387
- title: body.title,
4388
- ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
4389
- ...(typeof body.action === 'string' ? { action: body.action } : {}),
4390
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4391
- },
4392
- { source: normalizeAxSource(body.source, 'api') },
4393
- );
4394
- broadcastWorkbenchEvent('ax-state-changed', {
4395
- approvalGate,
4396
- sessionId: primaryWorkbenchSessionId,
4397
- timestamp: new Date().toISOString(),
4398
- });
4399
- return responseJson({ ok: true, approvalGate });
4400
- }
4401
-
4402
- async function handleAxApprovalResolve(req: Request, id: string): Promise<Response> {
4403
- const body = await readJson(req);
4404
- if (body.decision !== 'approved' && body.decision !== 'rejected') {
4405
- return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
4406
- }
4407
- const approvalGate = canvasState.resolveApproval(
4408
- id,
4409
- body.decision,
4410
- {
4411
- ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
4412
- source: normalizeAxSource(body.source, 'api'),
4413
- },
4414
- );
4415
- if (!approvalGate) return responseJson({ ok: false, error: 'approval gate not found or already resolved.' }, 404);
4416
- broadcastWorkbenchEvent('ax-state-changed', {
4417
- approvalGate,
4418
- sessionId: primaryWorkbenchSessionId,
4419
- timestamp: new Date().toISOString(),
4420
- });
4421
- return responseJson({ ok: true, approvalGate });
4422
- }
2721
+ // handleAxApprovalRequest / handleAxApprovalResolve migrated to the operation
2722
+ // registry (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4423
2723
 
4424
- async function handleAxEvidenceAdd(req: Request): Promise<Response> {
4425
- const body = await readJson(req);
4426
- if (!isAxEvidenceKind(body.kind) || typeof body.title !== 'string' || !body.title.trim()) {
4427
- return responseJson({ ok: false, error: 'evidence requires kind and title.' }, 400);
4428
- }
4429
- const evidence = canvasState.addEvidence(
4430
- {
4431
- kind: body.kind,
4432
- title: body.title,
4433
- body: typeof body.body === 'string' ? body.body : null,
4434
- ref: typeof body.ref === 'string' ? body.ref : null,
4435
- nodeIds: normalizeAxNodeIds(body.nodeIds),
4436
- data: isRecord(body.data) ? body.data : null,
4437
- },
4438
- { source: normalizeAxSource(body.source, 'api') },
4439
- );
4440
- broadcastWorkbenchEvent('ax-event-created', {
4441
- evidence,
4442
- sessionId: primaryWorkbenchSessionId,
4443
- timestamp: new Date().toISOString(),
4444
- });
4445
- return responseJson({ ok: true, evidence });
4446
- }
2724
+ // handleAxEvidenceAdd migrated to the operation registry (plan-007 Slice B
2725
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
4447
2726
 
4448
- const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
4449
- const AX_REVIEW_SEVERITIES = new Set(['info', 'warning', 'error']);
4450
- const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
4451
- const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
4452
-
4453
- function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
4454
- return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
4455
- }
4456
- function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
4457
- return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
4458
- }
4459
- function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
4460
- return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
4461
- }
4462
- function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
4463
- return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
4464
- }
4465
- function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
4466
- if (!isRecord(value)) return undefined;
4467
- return {
4468
- ...(typeof value.line === 'number' ? { line: value.line } : {}),
4469
- ...(typeof value.endLine === 'number' ? { endLine: value.endLine } : {}),
4470
- ...(typeof value.label === 'string' ? { label: value.label } : {}),
4471
- };
4472
- }
2727
+ // The AX review normalize helpers + their constant sets moved with the
2728
+ // migrated handlers (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4473
2729
 
4474
2730
  function handleAxReviewList(): Response {
4475
2731
  return responseJson({ ok: true, reviewAnnotations: canvasState.getReviewAnnotations() });
4476
2732
  }
4477
2733
 
4478
- async function handleAxReviewAdd(req: Request): Promise<Response> {
4479
- const body = await readJson(req);
4480
- if (typeof body.body !== 'string' || !body.body.trim()) {
4481
- return responseJson({ ok: false, error: 'review annotation requires a body.' }, 400);
4482
- }
4483
- const kind = normalizeAxReviewKind(body.kind);
4484
- const severity = normalizeAxReviewSeverity(body.severity);
4485
- const anchorType = normalizeAxReviewAnchor(body.anchorType);
4486
- const region = normalizeAxReviewRegion(body.region);
4487
- const reviewAnnotation = canvasState.addReviewAnnotation(
4488
- {
4489
- body: body.body,
4490
- ...(kind ? { kind } : {}),
4491
- ...(severity ? { severity } : {}),
4492
- ...(anchorType ? { anchorType } : {}),
4493
- ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
4494
- ...(typeof body.file === 'string' ? { file: body.file } : {}),
4495
- ...(region ? { region } : {}),
4496
- ...(typeof body.author === 'string' ? { author: body.author } : {}),
4497
- },
4498
- { source: normalizeAxSource(body.source, 'api') },
4499
- );
4500
- if (!reviewAnnotation) {
4501
- return responseJson({ ok: false, error: 'node-anchored review annotation requires a nodeId that exists on the canvas.' }, 400);
4502
- }
4503
- broadcastWorkbenchEvent('ax-state-changed', {
4504
- reviewAnnotation,
4505
- sessionId: primaryWorkbenchSessionId,
4506
- timestamp: new Date().toISOString(),
4507
- });
4508
- return responseJson({ ok: true, reviewAnnotation });
4509
- }
4510
-
4511
- async function handleAxReviewUpdate(req: Request, id: string): Promise<Response> {
4512
- const body = await readJson(req);
4513
- const status = normalizeAxReviewStatus(body.status);
4514
- const severity = normalizeAxReviewSeverity(body.severity);
4515
- const kind = normalizeAxReviewKind(body.kind);
4516
- const reviewAnnotation = canvasState.updateReviewAnnotation(
4517
- id,
4518
- {
4519
- ...(typeof body.body === 'string' ? { body: body.body } : {}),
4520
- ...(status ? { status } : {}),
4521
- ...(severity ? { severity } : {}),
4522
- ...(kind ? { kind } : {}),
4523
- },
4524
- { source: normalizeAxSource(body.source, 'api') },
4525
- );
4526
- if (!reviewAnnotation) return responseJson({ ok: false, error: 'review annotation not found.' }, 404);
4527
- broadcastWorkbenchEvent('ax-state-changed', {
4528
- reviewAnnotation,
4529
- sessionId: primaryWorkbenchSessionId,
4530
- timestamp: new Date().toISOString(),
4531
- });
4532
- return responseJson({ ok: true, reviewAnnotation });
4533
- }
2734
+ // handleAxReviewAdd / handleAxReviewUpdate migrated to the operation registry
2735
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4534
2736
 
4535
2737
  function handleAxHostCapabilityGet(): Response {
4536
2738
  return responseJson({ ok: true, host: canvasState.getHostCapability() });
4537
2739
  }
4538
2740
 
4539
- async function handleAxHostCapabilityReport(req: Request): Promise<Response> {
4540
- const body = await readJson(req);
4541
- const host = canvasState.setHostCapability(body, { source: normalizeAxSource(body.source, 'api') });
4542
- broadcastWorkbenchEvent('ax-state-changed', {
4543
- host,
4544
- sessionId: primaryWorkbenchSessionId,
4545
- timestamp: new Date().toISOString(),
4546
- });
4547
- return responseJson({ ok: true, host });
4548
- }
4549
-
4550
2741
  // ── Port resolution ───────────────────────────────────────────
4551
2742
 
4552
2743
  function buildPortCandidates(preferredPort: number): number[] {
@@ -5310,6 +3501,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5310
3501
  hostname: DEFAULT_HOST,
5311
3502
  port: portCandidate,
5312
3503
  idleTimeout: 0,
3504
+ // Last-resort boundary: any throw that escapes the fetch handler must NOT
3505
+ // render Bun's default dev error overlay (HTTP 500 text/html disclosing the
3506
+ // absolute server source path + stack). Return a clean JSON 500 and log the
3507
+ // real error server-side only. Operation dispatch has its own catch
3508
+ // (operations/http.ts); this covers the hand-written routes too.
3509
+ error(error) {
3510
+ logWorkbenchWarning('serverFetch', error);
3511
+ return responseJson({ ok: false, error: 'Internal server error.' }, 500);
3512
+ },
5313
3513
  async fetch(req) {
5314
3514
  const url = new URL(req.url);
5315
3515
 
@@ -5370,30 +3570,20 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5370
3570
  return handleWorkbenchIntent(req);
5371
3571
  }
5372
3572
 
5373
- if (url.pathname === '/api/workbench/webview' && req.method === 'GET') {
5374
- return handleWorkbenchWebViewStatus();
5375
- }
5376
-
5377
- if (url.pathname === '/api/workbench/webview/start' && req.method === 'POST') {
5378
- return handleWorkbenchWebViewStart(req);
5379
- }
5380
-
5381
- if (url.pathname === '/api/workbench/webview/evaluate' && req.method === 'POST') {
5382
- return handleWorkbenchWebViewEvaluate(req);
5383
- }
5384
-
5385
- if (url.pathname === '/api/workbench/webview/resize' && req.method === 'POST') {
5386
- return handleWorkbenchWebViewResize(req);
3573
+ // Webview automation routes (plan-008 Wave 3): status / start /
3574
+ // evaluate / resize / stop are now registered operations served by the
3575
+ // registry. A null return falls through (e.g. the screenshot route
3576
+ // below, which stays hand-written — it returns a binary image, not a
3577
+ // JSON wire body).
3578
+ if (url.pathname.startsWith('/api/workbench/webview')) {
3579
+ const webviewResponse = await dispatchOperationRoute(req, url);
3580
+ if (webviewResponse) return webviewResponse;
5387
3581
  }
5388
3582
 
5389
3583
  if (url.pathname === '/api/workbench/webview/screenshot' && req.method === 'POST') {
5390
3584
  return handleWorkbenchWebViewScreenshot(req);
5391
3585
  }
5392
3586
 
5393
- if (url.pathname === '/api/workbench/webview' && req.method === 'DELETE') {
5394
- return handleWorkbenchWebViewStop();
5395
- }
5396
-
5397
3587
  if (url.pathname === '/api/file/save' && req.method === 'POST') {
5398
3588
  return handleSave(req);
5399
3589
  }
@@ -5402,16 +3592,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5402
3592
  return handleRender(req);
5403
3593
  }
5404
3594
 
5405
- // Canvas state API
5406
- if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
5407
- const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
5408
- return responseJson(includeBlobs
5409
- ? serializeCanvasLayout(canvasState.getLayout())
5410
- : serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence())));
5411
- }
5412
-
5413
- if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
5414
- return responseJson(buildCanvasSummary());
3595
+ // Operation registry routes (plan-005): registered operations are
3596
+ // dispatched here; a null return falls through to the legacy routes.
3597
+ if (url.pathname.startsWith('/api/canvas/')) {
3598
+ const operationResponse = await dispatchOperationRoute(req, url);
3599
+ if (operationResponse) return operationResponse;
5415
3600
  }
5416
3601
 
5417
3602
  if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
@@ -5426,17 +3611,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5426
3611
  return handleCanvasUpdate(req);
5427
3612
  }
5428
3613
 
5429
- if (url.pathname === '/api/canvas/schema' && req.method === 'GET') {
5430
- return handleCanvasDescribeSchema();
5431
- }
5432
-
5433
- if (url.pathname === '/api/canvas/schema/validate' && req.method === 'POST') {
5434
- return handleCanvasValidateSpec(req);
5435
- }
5436
-
5437
- if (url.pathname === '/api/canvas/batch' && req.method === 'POST') {
5438
- return handleCanvasBatch(req);
5439
- }
3614
+ // POST /api/canvas/batch migrated to the operation registry
3615
+ // (plan-008 Wave 2): src/server/operations/ops/batch.ts.
5440
3616
 
5441
3617
  if (url.pathname === '/api/canvas/viewport' && req.method === 'POST') {
5442
3618
  return handleCanvasViewport(req);
@@ -5446,25 +3622,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5446
3622
  return handleCanvasAddAnnotation(req);
5447
3623
  }
5448
3624
 
5449
- if (url.pathname.startsWith('/api/canvas/annotation/') && req.method === 'DELETE') {
5450
- return handleCanvasRemoveAnnotation(url.pathname.slice('/api/canvas/annotation/'.length));
5451
- }
5452
-
5453
- if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
5454
- return handleCanvasAddNode(req);
5455
- }
5456
-
5457
- if (url.pathname === '/api/canvas/mcp-app/open' && req.method === 'POST') {
5458
- return handleCanvasOpenMcpApp(req);
5459
- }
5460
-
5461
- if (url.pathname === '/api/canvas/diagram' && req.method === 'POST') {
5462
- return handleCanvasAddDiagram(req);
5463
- }
5464
-
5465
- if (url.pathname === '/api/canvas/web-artifact' && req.method === 'POST') {
5466
- return handleCanvasBuildWebArtifact(req);
5467
- }
3625
+ // POST /api/canvas/mcp-app/open, /api/canvas/diagram, and
3626
+ // /api/canvas/web-artifact migrated to the operation registry
3627
+ // (plan-008 Wave 4): src/server/operations/ops/app.ts.
5468
3628
 
5469
3629
  // Individual node GET/PATCH/DELETE
5470
3630
  if (url.pathname.startsWith('/api/canvas/node/') && url.pathname.endsWith('/refresh') && req.method === 'POST') {
@@ -5472,111 +3632,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5472
3632
  return handleCanvasRefreshWebpageNode(nodeId, req);
5473
3633
  }
5474
3634
 
5475
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
5476
- const nodeId = decodeURIComponent(url.pathname.slice('/api/canvas/node/'.length));
5477
- const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
5478
- const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
5479
- if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
5480
- const responseNode = withContextPinReadState(node);
5481
- return responseJson(includeBlobs
5482
- ? serializeCanvasNode(responseNode)
5483
- : serializeCanvasNodeWithBlobSummaries(responseNode));
5484
- }
5485
-
5486
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
5487
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
5488
- return handleCanvasUpdateNode(nodeId, req);
5489
- }
5490
-
5491
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'DELETE') {
5492
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
5493
- closeNodeAppSession(canvasState.getNode(nodeId));
5494
- const result = removeCanvasNode(nodeId);
5495
- if (!result.removed) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
5496
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5497
- if (result.needsCodeGraphRecompute) {
5498
- scheduleCodeGraphRecompute(() => {
5499
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5500
- });
5501
- }
5502
- return responseJson({ ok: true, removed: nodeId });
5503
- }
5504
-
5505
3635
  if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
5506
3636
  return await handleCanvasImage(url.pathname);
5507
3637
  }
5508
3638
 
5509
- if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
5510
- return handleCanvasAddEdge(req);
5511
- }
5512
-
5513
- if (url.pathname === '/api/canvas/edge' && req.method === 'DELETE') {
5514
- return handleCanvasRemoveEdge(req);
5515
- }
5516
-
5517
- // Snapshot API
5518
- if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
5519
- return responseJson(listCanvasSnapshots({
5520
- limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
5521
- query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
5522
- before: url.searchParams.get('before') ?? undefined,
5523
- after: url.searchParams.get('after') ?? undefined,
5524
- all: url.searchParams.get('all') === 'true',
5525
- }));
5526
- }
5527
-
5528
- if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
5529
- return handleSnapshotSave(req);
5530
- }
5531
-
5532
- if (url.pathname === '/api/canvas/snapshots/gc' && req.method === 'POST') {
5533
- return handleSnapshotGc(req);
5534
- }
5535
-
5536
- if (url.pathname === '/api/canvas/snapshots/diff' && req.method === 'GET') {
5537
- const name = url.searchParams.get('name') ?? url.searchParams.get('id') ?? '';
5538
- if (!name.trim()) return responseJson({ ok: false, error: 'Missing snapshot name or id.' }, 400);
5539
- const snapshot = canvasState.getSnapshotData(name);
5540
- if (!snapshot) return responseJson({ ok: false, error: `Snapshot "${name}" not found.` }, 404);
5541
- const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
5542
- return responseJson({ ok: true, text: formatDiff(diff), diff });
5543
- }
5544
-
5545
- if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
5546
- const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
5547
- const snapshot = canvasState.getSnapshotData(id);
5548
- if (!snapshot) return responseJson({ ok: false, error: `Snapshot "${id}" not found.` }, 404);
5549
- const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
5550
- return responseJson({ ok: true, text: formatDiff(diff), diff });
5551
- }
5552
-
5553
- if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'POST') {
5554
- const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length));
5555
- const result = await restoreCanvasSnapshot(id);
5556
- if (!result.ok) return responseText('Snapshot not found', 404);
5557
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5558
- return responseJson({ ok: true });
5559
- }
5560
-
5561
- if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'DELETE') {
5562
- const id = url.pathname.split('/').pop() ?? '';
5563
- const result = deleteCanvasSnapshot(id);
5564
- if (!result.ok) return responseText('Snapshot not found', 404);
5565
- return responseJson({ ok: true });
5566
- }
5567
-
5568
- // Context pins API
5569
- if (url.pathname === '/api/canvas/context-pins' && req.method === 'POST') {
5570
- return handleContextPinsUpdate(req);
5571
- }
5572
-
5573
3639
  if (url.pathname === '/api/canvas/pinned-context' && req.method === 'GET') {
5574
3640
  return handleGetPinnedContext();
5575
3641
  }
5576
3642
 
5577
- if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
5578
- return handleGetAxState();
5579
- }
3643
+ // GET /api/canvas/ax migrated to the operation registry (plan-007 Slice B.1).
5580
3644
 
5581
3645
  if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
5582
3646
  return handleAxStatePatch(req);
@@ -5598,202 +3662,85 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5598
3662
  return handleOpenExternalSurface(req);
5599
3663
  }
5600
3664
 
5601
- if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
5602
- return handleAxFocusUpdate(req);
5603
- }
3665
+ // POST /api/canvas/ax/focus migrated to the operation registry (plan-007 Slice B.1).
5604
3666
 
5605
- if (url.pathname === '/api/canvas/ax/event' && req.method === 'POST') {
5606
- return handleAxEventAdd(req);
5607
- }
5608
-
5609
- if (url.pathname === '/api/canvas/ax/steer' && req.method === 'POST') {
5610
- return handleAxSteer(req);
5611
- }
5612
-
5613
- if (url.pathname === '/api/canvas/ax/timeline' && req.method === 'GET') {
5614
- return handleAxTimelineGet(url);
5615
- }
3667
+ // POST /api/canvas/ax/event + POST /api/canvas/ax/steer + GET
3668
+ // /api/canvas/ax/timeline migrated to the operation registry
3669
+ // (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
5616
3670
 
5617
3671
  if (url.pathname === '/api/canvas/ax/work' && req.method === 'GET') {
5618
3672
  return handleAxWorkList();
5619
3673
  }
5620
3674
 
5621
- if (url.pathname === '/api/canvas/ax/work' && req.method === 'POST') {
5622
- return handleAxWorkAdd(req);
5623
- }
5624
-
5625
- if (url.pathname.startsWith('/api/canvas/ax/work/') && req.method === 'PATCH') {
5626
- const workItemId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/work/'.length));
5627
- return handleAxWorkUpdate(req, workItemId);
5628
- }
3675
+ // POST /api/canvas/ax/work + PATCH /api/canvas/ax/work/:id migrated to
3676
+ // the operation registry (plan-007 Slice B wave 2).
5629
3677
 
5630
3678
  if (url.pathname === '/api/canvas/ax/approval' && req.method === 'GET') {
5631
3679
  return handleAxApprovalList();
5632
3680
  }
5633
3681
 
5634
- if (url.pathname === '/api/canvas/ax/approval' && req.method === 'POST') {
5635
- return handleAxApprovalRequest(req);
5636
- }
3682
+ // POST /api/canvas/ax/approval + POST /api/canvas/ax/approval/:id/resolve
3683
+ // migrated to the operation registry (plan-007 Slice B wave 2).
5637
3684
 
5638
- if (url.pathname.startsWith('/api/canvas/ax/approval/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5639
- const approvalId = decodeURIComponent(
5640
- url.pathname.slice('/api/canvas/ax/approval/'.length, -'/resolve'.length),
5641
- );
5642
- return handleAxApprovalResolve(req, approvalId);
5643
- }
3685
+ // GET /api/canvas/ax/approval/:id (single-item read + ?waitMs long-poll)
3686
+ // migrated to the operation registry (plan-007 Slice B wave 4).
5644
3687
 
5645
- if (url.pathname.startsWith('/api/canvas/ax/approval/') && !url.pathname.endsWith('/resolve') && req.method === 'GET') {
5646
- const approvalId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/approval/'.length));
5647
- return handleAxApprovalGet(url, approvalId, req);
5648
- }
5649
-
5650
- if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
5651
- return handleAxEvidenceAdd(req);
5652
- }
3688
+ // POST /api/canvas/ax/evidence migrated to the operation registry
3689
+ // (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
5653
3690
 
5654
3691
  if (url.pathname === '/api/canvas/ax/review' && req.method === 'GET') {
5655
3692
  return handleAxReviewList();
5656
3693
  }
5657
3694
 
5658
- if (url.pathname === '/api/canvas/ax/review' && req.method === 'POST') {
5659
- return handleAxReviewAdd(req);
5660
- }
5661
-
5662
- if (url.pathname.startsWith('/api/canvas/ax/review/') && req.method === 'PATCH') {
5663
- const reviewId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/review/'.length));
5664
- return handleAxReviewUpdate(req, reviewId);
5665
- }
3695
+ // POST /api/canvas/ax/review + PATCH /api/canvas/ax/review/:id migrated
3696
+ // to the operation registry (plan-007 Slice B wave 2).
5666
3697
 
5667
3698
  if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'GET') {
5668
3699
  return handleAxHostCapabilityGet();
5669
3700
  }
5670
3701
 
5671
- if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'PUT') {
5672
- return handleAxHostCapabilityReport(req);
5673
- }
3702
+ // PUT /api/canvas/ax/host-capability migrated to the operation registry (plan-007 Slice B.1).
5674
3703
 
5675
3704
  if (url.pathname === '/api/canvas/ax/interaction' && req.method === 'POST') {
5676
3705
  return handleAxInteraction(req);
5677
3706
  }
5678
3707
 
5679
- if (url.pathname === '/api/canvas/ax/delivery/pending' && req.method === 'GET') {
5680
- return handleAxDeliveryPending(url);
5681
- }
5682
-
5683
- if (url.pathname.startsWith('/api/canvas/ax/delivery/') && url.pathname.endsWith('/mark') && req.method === 'POST') {
5684
- const deliveryId = decodeURIComponent(
5685
- url.pathname.slice('/api/canvas/ax/delivery/'.length, -'/mark'.length),
5686
- );
5687
- return handleAxDeliveryMark(deliveryId);
5688
- }
3708
+ // GET /api/canvas/ax/delivery/pending + POST /api/canvas/ax/delivery/:id/mark
3709
+ // migrated to the operation registry (plan-007 Slice B wave 3):
3710
+ // src/server/operations/ops/ax-timeline.ts.
5689
3711
 
5690
3712
  if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'GET') {
5691
3713
  return handleAxElicitationList();
5692
3714
  }
5693
3715
 
5694
- if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'POST') {
5695
- return handleAxElicitationRequest(req);
5696
- }
3716
+ // POST /api/canvas/ax/elicitation + POST /api/canvas/ax/elicitation/:id/respond
3717
+ // migrated to the operation registry (plan-007 Slice B wave 2).
5697
3718
 
5698
- if (url.pathname.startsWith('/api/canvas/ax/elicitation/') && url.pathname.endsWith('/respond') && req.method === 'POST') {
5699
- const elicitationId = decodeURIComponent(
5700
- url.pathname.slice('/api/canvas/ax/elicitation/'.length, -'/respond'.length),
5701
- );
5702
- return handleAxElicitationRespond(req, elicitationId);
5703
- }
5704
-
5705
- if (url.pathname.startsWith('/api/canvas/ax/elicitation/') && !url.pathname.endsWith('/respond') && req.method === 'GET') {
5706
- const elicitationId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/elicitation/'.length));
5707
- return handleAxElicitationGet(url, elicitationId, req);
5708
- }
3719
+ // GET /api/canvas/ax/elicitation/:id (single-item read + ?waitMs long-poll)
3720
+ // migrated to the operation registry (plan-007 Slice B wave 4).
5709
3721
 
5710
3722
  if (url.pathname === '/api/canvas/ax/mode' && req.method === 'GET') {
5711
3723
  return handleAxModeList();
5712
3724
  }
5713
3725
 
5714
- if (url.pathname === '/api/canvas/ax/mode' && req.method === 'POST') {
5715
- return handleAxModeRequest(req);
5716
- }
3726
+ // POST /api/canvas/ax/mode + POST /api/canvas/ax/mode/:id/resolve migrated
3727
+ // to the operation registry (plan-007 Slice B wave 2).
5717
3728
 
5718
- if (url.pathname.startsWith('/api/canvas/ax/mode/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5719
- const modeId = decodeURIComponent(
5720
- url.pathname.slice('/api/canvas/ax/mode/'.length, -'/resolve'.length),
5721
- );
5722
- return handleAxModeResolve(req, modeId);
5723
- }
5724
-
5725
- if (url.pathname.startsWith('/api/canvas/ax/mode/') && !url.pathname.endsWith('/resolve') && req.method === 'GET') {
5726
- const modeId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/mode/'.length));
5727
- return handleAxModeGet(url, modeId, req);
5728
- }
3729
+ // GET /api/canvas/ax/mode/:id (single-item read + ?waitMs long-poll)
3730
+ // migrated to the operation registry (plan-007 Slice B wave 4).
5729
3731
 
5730
3732
  if (url.pathname === '/api/canvas/ax/command' && req.method === 'GET') {
5731
3733
  return handleAxCommandList();
5732
3734
  }
5733
3735
 
5734
- if (url.pathname === '/api/canvas/ax/command' && req.method === 'POST') {
5735
- return handleAxCommandInvoke(req);
5736
- }
3736
+ // POST /api/canvas/ax/command migrated to the operation registry
3737
+ // (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
5737
3738
 
5738
3739
  if (url.pathname === '/api/canvas/ax/policy' && req.method === 'GET') {
5739
3740
  return handleAxPolicyGet();
5740
3741
  }
5741
3742
 
5742
- if (url.pathname === '/api/canvas/ax/policy' && req.method === 'POST') {
5743
- return handleAxPolicySet(req);
5744
- }
5745
-
5746
- // Spatial context API
5747
- if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
5748
- const layout = canvasState.getLayout();
5749
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
5750
- return responseJson(spatial);
5751
- }
5752
-
5753
- // Search API
5754
- if (url.pathname === '/api/canvas/search' && req.method === 'GET') {
5755
- const q = url.searchParams.get('q') ?? '';
5756
- if (!q.trim()) {
5757
- return responseJson({ results: [], query: q });
5758
- }
5759
- const results = searchNodes(canvasState.getLayout().nodes, q);
5760
- return responseJson({ results, query: q });
5761
- }
5762
-
5763
- // Group API
5764
- if (url.pathname === '/api/canvas/group' && req.method === 'POST') {
5765
- return handleCanvasCreateGroup(req);
5766
- }
5767
-
5768
- if (url.pathname === '/api/canvas/group/add' && req.method === 'POST') {
5769
- return handleCanvasGroupNodes(req);
5770
- }
5771
-
5772
- if (url.pathname === '/api/canvas/group/ungroup' && req.method === 'POST') {
5773
- return handleCanvasUngroupNodes(req);
5774
- }
5775
-
5776
- // Arrange / Focus / Clear API (for agent CLI)
5777
- if (url.pathname === '/api/canvas/arrange' && req.method === 'POST') {
5778
- return handleCanvasArrange(req);
5779
- }
5780
-
5781
- if (url.pathname === '/api/canvas/focus' && req.method === 'POST') {
5782
- return handleCanvasFocus(req);
5783
- }
5784
-
5785
- if (url.pathname === '/api/canvas/fit' && req.method === 'POST') {
5786
- return handleCanvasFit(req);
5787
- }
5788
-
5789
- if (url.pathname === '/api/canvas/clear' && req.method === 'POST') {
5790
- for (const node of canvasState.getLayout().nodes) {
5791
- closeNodeAppSession(node);
5792
- }
5793
- clearCanvas();
5794
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5795
- return responseJson({ ok: true });
5796
- }
3743
+ // POST /api/canvas/ax/policy migrated to the operation registry (plan-007 Slice B.1).
5797
3744
 
5798
3745
  // Code graph API
5799
3746
  if (url.pathname === '/api/canvas/code-graph' && req.method === 'GET') {
@@ -5801,54 +3748,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5801
3748
  return responseJson(summary);
5802
3749
  }
5803
3750
 
5804
- if (url.pathname === '/api/canvas/json-render' && req.method === 'POST') {
5805
- return handleCanvasAddJsonRender(req);
5806
- }
5807
-
5808
- if (url.pathname === '/api/canvas/json-render/stream' && req.method === 'POST') {
5809
- return handleJsonRenderStream(req);
5810
- }
5811
-
5812
- if (url.pathname === '/api/canvas/graph' && req.method === 'POST') {
5813
- return handleCanvasAddGraph(req);
5814
- }
5815
-
5816
3751
  if (url.pathname === '/api/canvas/prompt' && req.method === 'POST') {
5817
3752
  return handleCanvasPrompt(req);
5818
3753
  }
5819
3754
 
5820
- // Undo/Redo/History API
5821
- if (url.pathname === '/api/canvas/undo' && req.method === 'POST') {
5822
- const entry = mutationHistory.undo();
5823
- if (!entry) return responseJson({ ok: false, description: 'Nothing to undo' });
5824
- await syncCanvasRuntimeBackends();
5825
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
5826
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5827
- return responseJson({ ok: true, description: `Undid: ${entry.description}` });
5828
- }
5829
-
5830
- if (url.pathname === '/api/canvas/redo' && req.method === 'POST') {
5831
- const entry = mutationHistory.redo();
5832
- if (!entry) return responseJson({ ok: false, description: 'Nothing to redo' });
5833
- await syncCanvasRuntimeBackends();
5834
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
5835
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5836
- return responseJson({ ok: true, description: `Redid: ${entry.description}` });
5837
- }
5838
-
5839
- if (url.pathname === '/api/canvas/history' && req.method === 'GET') {
5840
- return responseJson({
5841
- text: mutationHistory.toHumanReadable(),
5842
- entries: mutationHistory.getSummaries(),
5843
- canUndo: mutationHistory.canUndo(),
5844
- canRedo: mutationHistory.canRedo(),
5845
- });
5846
- }
5847
-
5848
- if (url.pathname === '/api/canvas/validate' && req.method === 'GET') {
5849
- return handleCanvasValidate();
5850
- }
5851
-
5852
3755
  if (url.pathname === '/api/ext-app/call-tool' && req.method === 'POST') {
5853
3756
  return handleExtAppCallTool(req);
5854
3757
  }