pmx-canvas 0.1.35 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +461 -0
  2. package/Readme.md +14 -2
  3. package/dist/canvas/index.js +82 -41
  4. package/dist/json-render/index.js +89 -334
  5. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  6. package/dist/types/mcp/canvas-access.d.ts +12 -159
  7. package/dist/types/server/ax-context.d.ts +1 -1
  8. package/dist/types/server/ax-state-manager.d.ts +256 -0
  9. package/dist/types/server/ax-state.d.ts +29 -1
  10. package/dist/types/server/ax-wait.d.ts +23 -0
  11. package/dist/types/server/canvas-operations.d.ts +1 -12
  12. package/dist/types/server/canvas-state.d.ts +46 -14
  13. package/dist/types/server/html-surface.d.ts +7 -0
  14. package/dist/types/server/index.d.ts +66 -26
  15. package/dist/types/server/operations/composites.d.ts +121 -0
  16. package/dist/types/server/operations/http.d.ts +7 -0
  17. package/dist/types/server/operations/index.d.ts +8 -0
  18. package/dist/types/server/operations/invoker.d.ts +13 -0
  19. package/dist/types/server/operations/mcp.d.ts +15 -0
  20. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  21. package/dist/types/server/operations/ops/app.d.ts +33 -0
  22. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  23. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  24. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  25. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  26. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  27. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  28. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  29. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  30. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  31. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  32. package/dist/types/server/operations/ops/query.d.ts +2 -0
  33. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  34. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  35. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  36. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  37. package/dist/types/server/operations/registry.d.ts +15 -0
  38. package/dist/types/server/operations/types.d.ts +116 -0
  39. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  40. package/docs/RELEASE.md +5 -0
  41. package/docs/adr-001-bun-only-runtime.md +46 -0
  42. package/docs/api-stability.md +57 -0
  43. package/docs/ax-host-adapter-contract.md +65 -0
  44. package/docs/ax-state-contract.md +72 -0
  45. package/docs/http-api.md +34 -2
  46. package/docs/mcp.md +64 -11
  47. package/docs/plans/plan-005-operation-registry.md +84 -0
  48. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  49. package/docs/plans/plan-007-ax-domain.md +99 -0
  50. package/docs/plans/plan-008-registry-finish.md +91 -0
  51. package/docs/screenshot.png +0 -0
  52. package/docs/tech-debt-assessment-2026-06.md +90 -0
  53. package/package.json +3 -3
  54. package/skills/pmx-canvas/SKILL.md +233 -185
  55. package/skills/pmx-canvas/evals/evals.json +3 -3
  56. package/skills/pmx-canvas/references/codex-app-adapter.md +24 -11
  57. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -1
  58. package/src/cli/agent.ts +52 -31
  59. package/src/client/nodes/ExtAppFrame.tsx +73 -5
  60. package/src/client/nodes/HtmlNode.tsx +12 -3
  61. package/src/client/nodes/McpAppNode.tsx +12 -3
  62. package/src/json-render/renderer/index.tsx +3 -0
  63. package/src/mcp/canvas-access.ts +43 -774
  64. package/src/mcp/server.ts +190 -2001
  65. package/src/server/ax-context.ts +7 -1
  66. package/src/server/ax-state-manager.ts +808 -0
  67. package/src/server/ax-state.ts +89 -2
  68. package/src/server/ax-wait.ts +56 -0
  69. package/src/server/canvas-operations.ts +2 -328
  70. package/src/server/canvas-schema.ts +2 -2
  71. package/src/server/canvas-state.ts +140 -382
  72. package/src/server/html-surface.ts +49 -11
  73. package/src/server/index.ts +136 -192
  74. package/src/server/operations/composites.ts +355 -0
  75. package/src/server/operations/http.ts +103 -0
  76. package/src/server/operations/index.ts +65 -0
  77. package/src/server/operations/invoker.ts +87 -0
  78. package/src/server/operations/mcp.ts +221 -0
  79. package/src/server/operations/ops/annotation.ts +60 -0
  80. package/src/server/operations/ops/app.ts +447 -0
  81. package/src/server/operations/ops/ax-await.ts +216 -0
  82. package/src/server/operations/ops/ax-shared.ts +38 -0
  83. package/src/server/operations/ops/ax-state.ts +249 -0
  84. package/src/server/operations/ops/ax-timeline.ts +381 -0
  85. package/src/server/operations/ops/ax-work.ts +635 -0
  86. package/src/server/operations/ops/batch.ts +365 -0
  87. package/src/server/operations/ops/edges.ts +166 -0
  88. package/src/server/operations/ops/groups.ts +176 -0
  89. package/src/server/operations/ops/json-render.ts +691 -0
  90. package/src/server/operations/ops/nodes.ts +1047 -0
  91. package/src/server/operations/ops/query.ts +281 -0
  92. package/src/server/operations/ops/snapshots.ts +366 -0
  93. package/src/server/operations/ops/validate.ts +37 -0
  94. package/src/server/operations/ops/viewport.ts +219 -0
  95. package/src/server/operations/ops/webview.ts +339 -0
  96. package/src/server/operations/registry.ts +79 -0
  97. package/src/server/operations/types.ts +150 -0
  98. package/src/server/operations/webview-runner.ts +77 -0
  99. package/src/server/server.ts +253 -2170
  100. 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,96 +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
71
  import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
82
- import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
72
+ import { isAxEvidenceKind, isAxActivityKind } from './ax-state.js';
83
73
  import type {
74
+ PmxAxEvidenceKind,
84
75
  PmxAxPolicy,
85
76
  PmxAxReviewAnchorType,
86
77
  PmxAxReviewKind,
87
- PmxAxReviewRegion,
88
78
  PmxAxReviewSeverity,
89
- PmxAxReviewStatus,
90
79
  PmxAxSource,
80
+ PmxAxWorkItemStatus,
91
81
  } from './ax-state.js';
92
82
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
93
83
  import { validateLocalImageFile } from './image-source.js';
94
84
  import {
95
- addCanvasNode,
96
- addCanvasEdge,
97
- MARKDOWN_NODE_DEFAULT_SIZE,
98
- MCP_APP_NODE_DEFAULT_SIZE,
99
- IMAGE_NODE_DEFAULT_SIZE,
100
- LEDGER_NODE_DEFAULT_SIZE,
101
85
  applyCanvasNodeUpdates,
102
- appendCanvasJsonRenderStream,
103
- buildStructuredNodeUpdate,
104
- arrangeCanvasNodes,
105
- clearCanvas,
106
- createCanvasGraphNode,
107
- createCanvasGroup,
108
- createCanvasJsonRenderNode,
109
- createCanvasStreamingJsonRenderNode,
110
- deleteCanvasSnapshot,
111
- executeCanvasBatch,
112
- fitCanvasView,
113
- gcCanvasSnapshots,
114
- groupCanvasNodes,
115
- listCanvasSnapshots,
116
86
  refreshCanvasWebpageNode,
117
- removeCanvasNode,
118
- removeCanvasEdge,
119
- resolveHtmlContent,
120
- restoreCanvasSnapshot,
121
- saveCanvasSnapshot,
122
- scheduleCodeGraphRecompute,
123
87
  primeCanvasRuntimeBackends,
124
88
  setCanvasLayoutUpdateEmitter,
125
89
  syncCanvasRuntimeBackends,
126
- setCanvasContextPins,
127
- ungroupCanvasNodes,
128
- validateCanvasNodePatch,
129
- hasStructuredNodeUpdateFields,
130
- hasTraceNodeDataFields,
131
- mergeTraceNodeDataFields,
132
90
  } from './canvas-operations.js';
133
- import { validateCanvasLayout } from './canvas-validation.js';
134
- import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
135
- 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';
136
97
  import {
137
98
  EXCALIDRAW_READ_CHECKPOINT_TOOL,
138
99
  EXCALIDRAW_SAVE_CHECKPOINT_TOOL,
139
100
  buildExcalidrawCheckpointId,
140
- buildExcalidrawOpenMcpAppInput,
141
101
  buildExcalidrawRestoreCheckpointToolInput,
142
102
  ensureExcalidrawCheckpointId,
143
103
  getExcalidrawCheckpointIdFromToolResult,
144
104
  isExcalidrawCreateView,
145
105
  } from './diagram-presets.js';
146
106
  import { traceManager } from './trace-manager.js';
147
- import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
148
107
  import {
149
108
  buildJsonRenderViewerHtml,
150
109
  } from '../json-render/server.js';
151
110
  import {
152
- WEBPAGE_NODE_DEFAULT_SIZE,
153
111
  normalizeWebpageUrl,
154
112
  } from './webpage-node.js';
155
113
  import type { JsonRenderSpec } from '../json-render/server.js';
@@ -169,6 +127,52 @@ let primaryWorkbenchAutoOpenEnabled = true;
169
127
  const initialCanvasThemeSetting = normalizeCanvasTheme(process.env.PMX_CANVAS_THEME);
170
128
  let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
171
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
+
172
176
  function normalizeGraphViewerSpec(
173
177
  node: { type: string; data: Record<string, unknown> },
174
178
  spec: JsonRenderSpec,
@@ -716,155 +720,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
716
720
  return value !== null && typeof value === 'object' && !Array.isArray(value);
717
721
  }
718
722
 
719
- function pickFiniteNumber(record: Record<string, unknown>, key: string): number | undefined {
720
- const value = record[key];
721
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
722
- }
723
-
724
- function getRecord(value: unknown): Record<string, unknown> | undefined {
725
- return isRecord(value) ? value : undefined;
726
- }
727
-
728
- function pickPositiveNumber(record: Record<string, unknown>, key: string): number | undefined {
729
- const value = pickFiniteNumber(record, key);
730
- return value !== undefined && value > 0 ? value : undefined;
731
- }
732
-
733
- function normalizeGeometryInput(body: Record<string, unknown>): {
734
- x?: number;
735
- y?: number;
736
- width?: number;
737
- height?: number;
738
- position?: { x?: number; y?: number };
739
- size?: { width?: number; height?: number };
740
- } {
741
- const position = getRecord(body.position);
742
- const size = getRecord(body.size);
743
- return {
744
- ...(pickFiniteNumber(body, 'x') !== undefined ? { x: pickFiniteNumber(body, 'x') } : {}),
745
- ...(pickFiniteNumber(body, 'y') !== undefined ? { y: pickFiniteNumber(body, 'y') } : {}),
746
- ...(pickFiniteNumber(body, 'width') !== undefined ? { width: pickFiniteNumber(body, 'width') } : {}),
747
- ...(pickFiniteNumber(body, 'height') !== undefined ? { height: pickFiniteNumber(body, 'height') } : {}),
748
- ...(position ? {
749
- position: {
750
- ...(pickFiniteNumber(position, 'x') !== undefined ? { x: pickFiniteNumber(position, 'x') } : {}),
751
- ...(pickFiniteNumber(position, 'y') !== undefined ? { y: pickFiniteNumber(position, 'y') } : {}),
752
- },
753
- } : {}),
754
- ...(size ? {
755
- size: {
756
- ...(pickFiniteNumber(size, 'width') !== undefined ? { width: pickFiniteNumber(size, 'width') } : {}),
757
- ...(pickFiniteNumber(size, 'height') !== undefined ? { height: pickFiniteNumber(size, 'height') } : {}),
758
- },
759
- } : {}),
760
- };
761
- }
762
-
763
- function resolveCreateGeometry(body: Record<string, unknown>): {
764
- x?: number;
765
- y?: number;
766
- width?: number;
767
- height?: number;
768
- } {
769
- const geometry = normalizeGeometryInput(body);
770
- const x = geometry.x ?? geometry.position?.x;
771
- const y = geometry.y ?? geometry.position?.y;
772
- const width = geometry.width ?? geometry.size?.width;
773
- const height = geometry.height ?? geometry.size?.height;
774
- return {
775
- ...(x !== undefined ? { x } : {}),
776
- ...(y !== undefined ? { y } : {}),
777
- ...(width !== undefined ? { width } : {}),
778
- ...(height !== undefined ? { height } : {}),
779
- };
780
- }
781
-
782
- function resolvePatchGeometry(
783
- body: Record<string, unknown>,
784
- existing: CanvasNodeState,
785
- ): {
786
- position?: { x: number; y: number };
787
- size?: { width: number; height: number };
788
- } {
789
- const geometry = normalizeGeometryInput(body);
790
- const x = geometry.x ?? geometry.position?.x;
791
- const y = geometry.y ?? geometry.position?.y;
792
- const width = geometry.width ?? geometry.size?.width;
793
- const height = geometry.height ?? geometry.size?.height;
794
- return {
795
- ...(x !== undefined || y !== undefined
796
- ? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
797
- : {}),
798
- ...(width !== undefined || height !== undefined
799
- ? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
800
- : {}),
801
- };
802
- }
803
-
804
- function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> | null {
805
- if (!Array.isArray(value)) return null;
806
- if (value.some((item) => !isRecord(item))) return null;
807
- return value as Array<Record<string, unknown>>;
808
- }
809
-
810
- type StringListField = { value?: string[]; error?: string };
811
-
812
- function parseStringListField(field: string, value: unknown): StringListField {
813
- if (value === undefined) return {};
814
- if (!Array.isArray(value)) return { error: `"${field}" must be an array of node IDs.` };
815
- const invalid = value.find((item) => typeof item !== 'string' || item.trim().length === 0);
816
- if (invalid !== undefined) return { error: `"${field}" must contain only non-empty node IDs.` };
817
- return { value };
818
- }
819
-
820
- function pickGroupChildIds(body: Record<string, unknown>): StringListField {
821
- if ('children' in body) return parseStringListField('children', body.children);
822
- if ('childIds' in body) return parseStringListField('childIds', body.childIds);
823
- const data = isRecord(body.data) ? body.data : undefined;
824
- return data && 'children' in data ? parseStringListField('data.children', data.children) : {};
825
- }
826
-
827
- function validateGroupChildIds(groupId: string, childIds: string[]): string | null {
828
- const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
829
- if (missingChildIds.length > 0) {
830
- return `Missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`;
831
- }
832
- const invalidChildIds = childIds.filter((id) => {
833
- const node = canvasState.getNode(id);
834
- return id === groupId || node?.type === 'group';
835
- });
836
- if (invalidChildIds.length > 0) {
837
- return `Invalid group child ID${invalidChildIds.length === 1 ? '' : 's'}: ${invalidChildIds.join(', ')}.`;
838
- }
839
- return null;
840
- }
841
-
842
- function setGroupChildrenFromApi(groupId: string, childIds: string[]): boolean {
843
- const group = canvasState.getNode(groupId);
844
- if (!group || group.type !== 'group') return false;
845
-
846
- const dataChildIds = Array.isArray(group.data.children)
847
- ? group.data.children.filter((id): id is string => typeof id === 'string')
848
- : [];
849
- const parentBackrefIds = canvasState.getLayout().nodes
850
- .filter((node) => node.id !== groupId && node.data.parentGroup === groupId)
851
- .map((node) => node.id);
852
- const currentChildIds = [...new Set([...dataChildIds, ...parentBackrefIds])];
853
- if (currentChildIds.length > 0) {
854
- if (currentChildIds.length !== dataChildIds.length || currentChildIds.some((id) => !dataChildIds.includes(id))) {
855
- canvasState.updateNode(groupId, { data: { ...group.data, children: currentChildIds } });
856
- }
857
- canvasState.ungroupNodes(groupId);
858
- }
859
- if (childIds.length === 0) return true;
860
-
861
- const latestGroup = canvasState.getNode(groupId);
862
- return canvasState.groupNodes(groupId, childIds, {
863
- preservePositions: true,
864
- keepGroupFrame: latestGroup?.data.frameMode === 'manual',
865
- });
866
- }
867
-
868
723
  function getExtAppNodeCheckpointId(node: CanvasNodeState): string {
869
724
  const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
870
725
  const storedCheckpointId = appCheckpoint?.id;
@@ -1072,34 +927,6 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
1072
927
  }
1073
928
  }
1074
929
 
1075
- /**
1076
- * Like {@link readJson}, but PRESERVES a top-level JSON array. For endpoints that
1077
- * accept either an object or a bare array (e.g. `/api/canvas/batch`, whose CLI
1078
- * help and handler both document a bare `[...]` form). readJson coerces arrays to
1079
- * `{}` so object-shaped handlers never crash on `body.field`; this variant keeps
1080
- * the array so the handler's array branch can run. Empty/whitespace/malformed
1081
- * bodies still resolve to `{}`.
1082
- */
1083
- async function readJsonObjectOrArray(req: Request): Promise<Record<string, unknown> | unknown[]> {
1084
- let text = '';
1085
- try {
1086
- text = await req.text();
1087
- } catch (error) {
1088
- logWorkbenchWarning('readJson', error);
1089
- return {};
1090
- }
1091
- if (!text.trim()) return {};
1092
- try {
1093
- const value = JSON.parse(text) as unknown;
1094
- if (Array.isArray(value)) return value;
1095
- if (!value || typeof value !== 'object') return {};
1096
- return value as Record<string, unknown>;
1097
- } catch (error) {
1098
- logWorkbenchWarning('readJson', error);
1099
- return {};
1100
- }
1101
- }
1102
-
1103
930
  function htmlEscape(value: string): string {
1104
931
  return value
1105
932
  .replaceAll('&', '&amp;')
@@ -1640,14 +1467,6 @@ async function handleCanvasAddAnnotation(req: Request): Promise<Response> {
1640
1467
  return responseJson({ ok: true, annotation: summarizeCanvasAnnotation(annotation) });
1641
1468
  }
1642
1469
 
1643
- function handleCanvasRemoveAnnotation(id: string): Response {
1644
- const decodedId = decodeURIComponent(id);
1645
- const removed = canvasState.removeAnnotation(decodedId);
1646
- if (!removed) return responseJson({ ok: false, error: `Annotation "${decodedId}" not found.` }, 404);
1647
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1648
- return responseJson({ ok: true, removed: decodedId });
1649
- }
1650
-
1651
1470
  // ── Serve image file for image nodes ─────────────────────────
1652
1471
  async function handleCanvasImage(pathname: string): Promise<Response> {
1653
1472
  const nodeId = pathname.replace('/api/canvas/image/', '');
@@ -1684,372 +1503,6 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1684
1503
  });
1685
1504
  }
1686
1505
 
1687
- // ── Add node from client ─────────────────────────────────────
1688
- const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'html', 'group']);
1689
-
1690
- function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1691
- const serialized = serializeCanvasNode(node);
1692
- return {
1693
- ok: true,
1694
- node: serialized,
1695
- ...serialized,
1696
- // `nodeId` aliases `id` so HTTP/CLI node-create responses match the MCP
1697
- // createdNodePayload — agents using either key (or a cached schema) work.
1698
- nodeId: node.id,
1699
- };
1700
- }
1701
-
1702
- function withContextPinReadState(node: CanvasNodeState): CanvasNodeState {
1703
- return {
1704
- ...node,
1705
- pinned: node.pinned || canvasState.contextPinnedNodeIds.has(node.id),
1706
- };
1707
- }
1708
-
1709
- function withContextPinLayoutReadState(layout: CanvasLayout): CanvasLayout {
1710
- return {
1711
- ...layout,
1712
- nodes: layout.nodes.map(withContextPinReadState),
1713
- };
1714
- }
1715
-
1716
- async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
1717
- const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
1718
- ? body.url
1719
- : typeof body.content === 'string'
1720
- ? body.content
1721
- : '';
1722
-
1723
- let normalizedUrl: string;
1724
- try {
1725
- normalizedUrl = normalizeWebpageUrl(rawUrl);
1726
- } catch (error) {
1727
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
1728
- }
1729
-
1730
- const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1731
- ? body.data as Record<string, unknown>
1732
- : undefined;
1733
- const geometry = resolveCreateGeometry(body);
1734
- const { id, node } = addCanvasNode({
1735
- type: 'webpage',
1736
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1737
- content: normalizedUrl,
1738
- ...(extraData ? { data: extraData } : {}),
1739
- ...(body.strictSize === true ? { strictSize: true } : {}),
1740
- ...geometry,
1741
- ...(geometry.width === undefined ? { width: WEBPAGE_NODE_DEFAULT_SIZE.width } : {}),
1742
- ...(geometry.height === undefined ? { height: WEBPAGE_NODE_DEFAULT_SIZE.height } : {}),
1743
- });
1744
-
1745
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1746
- const refreshed = await refreshCanvasWebpageNode(id);
1747
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1748
- const created = canvasState.getNode(id) ?? node;
1749
- return responseJson({
1750
- ...buildNodeResponse(created),
1751
- fetch: refreshed.ok
1752
- ? { ok: true }
1753
- : { ok: false, error: refreshed.error ?? 'Failed to fetch webpage content.' },
1754
- ...(refreshed.ok ? {} : { error: refreshed.error }),
1755
- });
1756
- }
1757
-
1758
- async function handleCanvasAddNode(req: Request): Promise<Response> {
1759
- const body = await readJson(req);
1760
- const queryType = new URL(req.url).searchParams.get('type');
1761
- const type = typeof body.type === 'string' ? body.type : queryType || 'markdown';
1762
-
1763
- if (!VALID_NODE_TYPES.has(type)) {
1764
- if (type === 'json-render') {
1765
- return responseJson({
1766
- ok: false,
1767
- error: 'Node type "json-render" is created via POST /api/canvas/json-render. See /api/canvas/schema for the required spec shape.',
1768
- }, 400);
1769
- }
1770
- if (type === 'graph') {
1771
- return responseJson({
1772
- ok: false,
1773
- error: 'Node type "graph" is created via POST /api/canvas/graph. See /api/canvas/schema for graphType + data fields.',
1774
- }, 400);
1775
- }
1776
- if (type === 'web-artifact') {
1777
- return responseJson({
1778
- ok: false,
1779
- error: 'Node type "web-artifact" is created via POST /api/canvas/web-artifact with appTsx + title.',
1780
- }, 400);
1781
- }
1782
- if (type === 'html-primitive') {
1783
- return createCanvasHtmlPrimitiveNode(body);
1784
- }
1785
- return responseJson({ ok: false, error: `Invalid node type: "${type}".` }, 400);
1786
- }
1787
-
1788
- if (type === 'webpage') {
1789
- return createCanvasWebpageNode(body);
1790
- }
1791
-
1792
- if (type === 'html' && (typeof body.primitive === 'string' || typeof body.kind === 'string')) {
1793
- return createCanvasHtmlPrimitiveNode(body);
1794
- }
1795
-
1796
- if (type === 'group') {
1797
- const geometry = resolveCreateGeometry(body);
1798
- const childList = pickGroupChildIds(body);
1799
- if (childList.error) return responseJson({ ok: false, error: `Cannot create group: ${childList.error}` }, 400);
1800
- const childIds = childList.value ?? [];
1801
- const childError = validateGroupChildIds('', childIds);
1802
- if (childError) return responseJson({ ok: false, error: `Cannot create group: ${childError}` }, 400);
1803
- const { node } = createCanvasGroup({
1804
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1805
- childIds,
1806
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
1807
- ...geometry,
1808
- });
1809
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1810
- return responseJson(buildNodeResponse(node));
1811
- }
1812
-
1813
- const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1814
- ? body.data as Record<string, unknown>
1815
- : undefined;
1816
- if (type === 'html') {
1817
- if ('html' in body && typeof body.html !== 'string') {
1818
- return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
1819
- }
1820
- if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
1821
- return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
1822
- }
1823
- }
1824
- const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1825
- ? body.path
1826
- : body.content;
1827
- // For html nodes, accept top-level `html` field and merge into data so callers
1828
- // can POST { type: 'html', title, html } without nesting under `data`.
1829
- const htmlMergedData = type === 'html'
1830
- ? {
1831
- ...(extraData ?? {}),
1832
- ...(typeof body.html === 'string' ? { html: resolveHtmlContent(body.html) } : {}),
1833
- ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
1834
- ...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
1835
- ...(typeof body.description === 'string' ? { description: body.description } : {}),
1836
- ...(body.presentation === true ? { presentation: true } : {}),
1837
- ...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
1838
- ...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
1839
- ...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
1840
- }
1841
- : extraData;
1842
- let added: ReturnType<typeof addCanvasNode>;
1843
- const geometry = resolveCreateGeometry(body);
1844
- try {
1845
- added = addCanvasNode({
1846
- type: type as CanvasNodeState['type'],
1847
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1848
- ...(typeof content === 'string' ? { content } : {}),
1849
- ...(htmlMergedData && Object.keys(htmlMergedData).length > 0 ? { data: htmlMergedData } : {}),
1850
- ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
1851
- ...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
1852
- ...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
1853
- ...(type === 'trace' && typeof body.duration === 'string' ? { duration: body.duration } : {}),
1854
- ...(type === 'trace' && typeof body.resultSummary === 'string' ? { resultSummary: body.resultSummary } : {}),
1855
- ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1856
- ...(body.strictSize === true ? { strictSize: true } : {}),
1857
- ...geometry,
1858
- defaultWidth: type === 'html'
1859
- ? 720
1860
- : type === 'markdown'
1861
- ? MARKDOWN_NODE_DEFAULT_SIZE.width
1862
- : type === 'mcp-app'
1863
- ? MCP_APP_NODE_DEFAULT_SIZE.width
1864
- : type === 'image'
1865
- ? IMAGE_NODE_DEFAULT_SIZE.width
1866
- : type === 'ledger'
1867
- ? LEDGER_NODE_DEFAULT_SIZE.width
1868
- : 360,
1869
- defaultHeight: type === 'html'
1870
- ? 640
1871
- : type === 'markdown'
1872
- ? MARKDOWN_NODE_DEFAULT_SIZE.height
1873
- : type === 'mcp-app'
1874
- ? MCP_APP_NODE_DEFAULT_SIZE.height
1875
- : type === 'image'
1876
- ? IMAGE_NODE_DEFAULT_SIZE.height
1877
- : type === 'ledger'
1878
- ? LEDGER_NODE_DEFAULT_SIZE.height
1879
- : 200,
1880
- fileMode: 'auto',
1881
- });
1882
- } catch (error) {
1883
- const message = error instanceof Error ? error.message : String(error);
1884
- return responseJson({ ok: false, error: message }, 400);
1885
- }
1886
- const { node, needsCodeGraphRecompute } = added;
1887
-
1888
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1889
- if (needsCodeGraphRecompute) {
1890
- scheduleCodeGraphRecompute(() => {
1891
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1892
- });
1893
- }
1894
- return responseJson(buildNodeResponse(node));
1895
- }
1896
-
1897
- function createCanvasHtmlPrimitiveNode(body: Record<string, unknown>): Response {
1898
- const rawKind = typeof body.primitive === 'string' ? body.primitive : body.kind;
1899
- if (typeof rawKind !== 'string' || !isHtmlPrimitiveKind(rawKind)) {
1900
- return responseJson({ ok: false, error: `Unknown HTML primitive: ${String(rawKind)}.` }, 400);
1901
- }
1902
- const data = isRecord(body.data) ? body.data : {};
1903
- let built: ReturnType<typeof buildHtmlPrimitive>;
1904
- try {
1905
- built = buildHtmlPrimitive({
1906
- kind: rawKind,
1907
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
1908
- data,
1909
- });
1910
- } catch (error) {
1911
- return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1912
- }
1913
- const geometry = resolveCreateGeometry(body);
1914
- const { node } = addCanvasNode({
1915
- type: 'html',
1916
- title: built.title,
1917
- data: {
1918
- html: built.html,
1919
- htmlPrimitive: built.kind,
1920
- primitiveData: built.data,
1921
- description: built.summary,
1922
- agentSummary: typeof data.agentSummary === 'string' ? data.agentSummary : built.summary,
1923
- ...(typeof data.summary === 'string' ? { summary: data.summary } : {}),
1924
- ...getHtmlPrimitiveSemanticMetadata(built.data),
1925
- },
1926
- ...(body.strictSize === true ? { strictSize: true } : {}),
1927
- ...geometry,
1928
- defaultWidth: built.defaultSize.width,
1929
- defaultHeight: built.defaultSize.height,
1930
- });
1931
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1932
- return responseJson({
1933
- ...buildNodeResponse(node),
1934
- primitive: {
1935
- kind: built.kind,
1936
- title: built.title,
1937
- htmlBytes: Buffer.byteLength(built.html, 'utf-8'),
1938
- defaultSize: built.defaultSize,
1939
- },
1940
- });
1941
- }
1942
-
1943
- // ── Group operations ─────────────────────────────────────────
1944
- async function handleCanvasCreateGroup(req: Request): Promise<Response> {
1945
- const body = await readJson(req);
1946
- const title = typeof body.title === 'string' ? body.title : 'Group';
1947
- const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
1948
- const color = typeof body.color === 'string' ? body.color : undefined;
1949
- const x = typeof body.x === 'number' ? body.x : undefined;
1950
- const y = typeof body.y === 'number' ? body.y : undefined;
1951
- const width = typeof body.width === 'number' ? body.width : undefined;
1952
- const height = typeof body.height === 'number' ? body.height : undefined;
1953
- const childLayout =
1954
- body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1955
- ? body.childLayout
1956
- : undefined;
1957
- if (childIds.length > 0) {
1958
- const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
1959
- if (missingChildIds.length > 0) {
1960
- return responseJson({
1961
- ok: false,
1962
- error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
1963
- }, 400);
1964
- }
1965
- }
1966
-
1967
- const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
1968
-
1969
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1970
- return responseJson(buildNodeResponse(node));
1971
- }
1972
-
1973
- async function handleCanvasGroupNodes(req: Request): Promise<Response> {
1974
- const body = await readJson(req);
1975
- const groupId = body.groupId as string;
1976
- const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
1977
- const childLayout =
1978
- body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1979
- ? body.childLayout
1980
- : undefined;
1981
- if (!groupId || childIds.length === 0) {
1982
- return responseJson({ ok: false, error: 'Missing groupId or childIds.' }, 400);
1983
- }
1984
- const { ok } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
1985
- if (!ok) return responseJson({ ok: false, error: 'Group not found or no valid children.' }, 400);
1986
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1987
- return responseJson({ ok: true, groupId });
1988
- }
1989
-
1990
- async function handleCanvasUngroupNodes(req: Request): Promise<Response> {
1991
- const body = await readJson(req);
1992
- const groupId = body.groupId as string;
1993
- if (!groupId) return responseJson({ ok: false, error: 'Missing groupId.' }, 400);
1994
- const { ok } = ungroupCanvasNodes(groupId);
1995
- if (!ok) return responseJson({ ok: false, error: 'Group not found or empty.' }, 400);
1996
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1997
- return responseJson({ ok: true, groupId });
1998
- }
1999
-
2000
- const VALID_EDGE_TYPES = new Set(['relation', 'depends-on', 'flow', 'references']);
2001
- const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
2002
-
2003
- async function handleCanvasAddEdge(req: Request): Promise<Response> {
2004
- const body = await readJson(req);
2005
- const type = body.type as string;
2006
- const style = typeof body.style === 'string' ? body.style : undefined;
2007
-
2008
- if (
2009
- !type ||
2010
- (!body.from && !body.fromSearch) ||
2011
- (!body.to && !body.toSearch)
2012
- ) {
2013
- return responseJson({ ok: false, error: 'Missing required fields: type plus from/fromSearch and to/toSearch.' }, 400);
2014
- }
2015
- if (!VALID_EDGE_TYPES.has(type)) {
2016
- return responseJson({ ok: false, error: `Invalid edge type: "${type}".` }, 400);
2017
- }
2018
- if (style && !VALID_EDGE_STYLES.has(style)) {
2019
- return responseJson({ ok: false, error: `Invalid edge style: "${style}". Use solid, dashed, or dotted.` }, 400);
2020
- }
2021
- try {
2022
- const result = addCanvasEdge({
2023
- ...(typeof body.from === 'string' ? { from: body.from } : {}),
2024
- ...(typeof body.to === 'string' ? { to: body.to } : {}),
2025
- ...(typeof body.fromSearch === 'string' ? { fromSearch: body.fromSearch } : {}),
2026
- ...(typeof body.toSearch === 'string' ? { toSearch: body.toSearch } : {}),
2027
- type: type as CanvasEdge['type'],
2028
- ...(body.label ? { label: String(body.label) } : {}),
2029
- ...(style ? { style: style as CanvasEdge['style'] } : {}),
2030
- ...(body.animated !== undefined ? { animated: Boolean(body.animated) } : {}),
2031
- });
2032
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2033
- return responseJson({ ok: true, ...result });
2034
- } catch (error) {
2035
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Duplicate or self-edge.' }, 400);
2036
- }
2037
- }
2038
-
2039
- async function handleCanvasRemoveEdge(req: Request): Promise<Response> {
2040
- const body = await readJson(req);
2041
- const edgeId = body.edge_id as string;
2042
- if (!edgeId) {
2043
- return responseJson({ ok: false, error: 'Missing edge_id.' }, 400);
2044
- }
2045
- const { removed } = removeCanvasEdge(edgeId);
2046
- if (!removed) {
2047
- return responseJson({ ok: false, error: `Edge "${edgeId}" not found.` }, 404);
2048
- }
2049
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2050
- return responseJson({ ok: true, removed: edgeId });
2051
- }
2052
-
2053
1506
  async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Promise<Response> {
2054
1507
  const existing = canvasState.getNode(nodeId);
2055
1508
  if (!existing || existing.type !== 'webpage') {
@@ -2072,504 +1525,32 @@ async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Pro
2072
1525
  return responseJson(result, result.ok ? 200 : 400);
2073
1526
  }
2074
1527
 
2075
- // ── Individual node update (PATCH) ──────────────────────────
2076
- async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Response> {
2077
- const existing = canvasState.getNode(nodeId);
2078
- if (!existing) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
2079
- const body = await readJson(req);
2080
- if (existing.type === 'webpage' && body.refresh === true) {
2081
- return handleCanvasRefreshWebpageNode(nodeId, req);
2082
- }
2083
- const groupChildList = existing.type === 'group' ? pickGroupChildIds(body) : {};
2084
- if (groupChildList.error) return responseJson({ ok: false, error: `Cannot update group: ${groupChildList.error}` }, 400);
2085
- const groupChildIds = groupChildList.value;
2086
- if (groupChildIds !== undefined) {
2087
- const childError = validateGroupChildIds(nodeId, groupChildIds);
2088
- if (childError) return responseJson({ ok: false, error: `Cannot update group: ${childError}` }, 400);
2089
- }
2090
- const patch: Record<string, unknown> = resolvePatchGeometry(body, existing);
2091
- if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
2092
- if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
2093
- if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
2094
- patch.dockPosition = body.dockPosition;
2095
- }
2096
- if (hasStructuredNodeUpdateFields(body)) {
2097
- try {
2098
- patch.data = buildStructuredNodeUpdate(existing, body).data;
2099
- } catch (error) {
2100
- return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
2101
- }
2102
- } else if (
2103
- body.title !== undefined ||
2104
- body.content !== undefined ||
2105
- body.data ||
2106
- typeof body.arrangeLocked === 'boolean' ||
2107
- typeof body.strictSize === 'boolean' ||
2108
- (existing.type === 'trace' && hasTraceNodeDataFields(body))
2109
- ) {
2110
- const data = { ...existing.data };
2111
- if (body.title !== undefined) {
2112
- data.title = String(body.title);
2113
- if (existing.type === 'webpage') {
2114
- data.titleSource = 'user';
2115
- }
2116
- }
2117
- if (body.content !== undefined) data.content = String(body.content);
2118
- if (typeof body.arrangeLocked === 'boolean') data.arrangeLocked = body.arrangeLocked;
2119
- if (typeof body.strictSize === 'boolean') data.strictSize = body.strictSize;
2120
- // Merge extra data fields (for status, context, ledger, trace nodes)
2121
- if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
2122
- Object.assign(data, body.data as Record<string, unknown>);
2123
- }
2124
- if (existing.type === 'webpage') {
2125
- const nextUrl = typeof body.url === 'string'
2126
- ? body.url
2127
- : typeof (body.data as Record<string, unknown> | undefined)?.url === 'string'
2128
- ? (body.data as Record<string, unknown>).url as string
2129
- : undefined;
2130
- if (typeof nextUrl === 'string' && nextUrl.trim().length > 0) {
2131
- try {
2132
- data.url = normalizeWebpageUrl(nextUrl);
2133
- } catch (error) {
2134
- return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
2135
- }
2136
- }
2137
- }
2138
- patch.data = existing.type === 'trace'
2139
- ? mergeTraceNodeDataFields(data, body)
2140
- : data;
2141
- }
2142
- const error = validateCanvasNodePatch({
2143
- ...(patch.position ? { position: patch.position as { x: number; y: number } } : {}),
2144
- ...(patch.size ? { size: patch.size as { width: number; height: number } } : {}),
2145
- });
2146
- if (error) return responseJson({ ok: false, error }, 400);
2147
- canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
2148
- if (groupChildIds !== undefined && !setGroupChildrenFromApi(nodeId, groupChildIds)) {
2149
- return responseJson({ ok: false, error: `Group "${nodeId}" not found.` }, 404);
2150
- }
2151
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2152
- const updated = canvasState.getNode(nodeId);
2153
- return responseJson(updated ? buildNodeResponse(updated) : { ok: true, id: nodeId });
2154
- }
2155
-
2156
- // ── Arrange nodes ───────────────────────────────────────────
2157
- async function handleCanvasArrange(req: Request): Promise<Response> {
2158
- const body = await readJson(req);
2159
- const layout = typeof body.layout === 'string' ? body.layout : 'grid';
2160
- if (!['grid', 'column', 'flow'].includes(layout)) {
2161
- return responseJson({ ok: false, error: `Invalid layout: "${layout}". Use: grid, column, flow` }, 400);
2162
- }
2163
- const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
2164
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2165
- const validation = validateCanvasLayout(canvasState.getLayout());
2166
- return responseJson({
2167
- ok: validation.ok,
2168
- arranged: result.arranged,
2169
- layout: result.layout,
2170
- ...(validation.ok ? {} : { validation, collisions: validation.summary.collisions }),
2171
- });
2172
- }
1528
+ // handleCanvasBuildWebArtifact migrated to the operation registry
1529
+ // (plan-008 Wave 4): src/server/operations/ops/app.ts (webartifact.build).
2173
1530
 
2174
- // ── Focus on node ───────────────────────────────────────────
2175
- async function handleCanvasFocus(req: Request): Promise<Response> {
1531
+ async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2176
1532
  const body = await readJson(req);
2177
- const nodeId = body.id as string;
2178
- if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
2179
- const node = canvasState.getNode(nodeId);
2180
- if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
2181
- const noPan = body.noPan === true;
2182
- if (!noPan) {
2183
- canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
2184
- } else {
2185
- const maxZ = canvasState.getLayout().nodes.reduce((max, layoutNode) => Math.max(max, layoutNode.zIndex), 0);
2186
- canvasState.updateNode(nodeId, { zIndex: maxZ + 1 });
2187
- }
2188
- const focus = canvasState.setAxFocus([nodeId], { source: 'api', recordHistory: false });
2189
- broadcastWorkbenchEvent('ax-state-changed', {
2190
- focus,
1533
+ const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
1534
+ const next = canvasState.setTheme(theme);
1535
+ broadcastWorkbenchEvent('theme-changed', {
1536
+ theme: next,
2191
1537
  sessionId: primaryWorkbenchSessionId,
2192
1538
  timestamp: new Date().toISOString(),
2193
1539
  });
2194
- emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId, noPan });
2195
- if (!noPan) emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
2196
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2197
- return responseJson({ ok: true, focused: nodeId, panned: !noPan, axFocus: focus });
2198
- }
2199
-
2200
- async function handleCanvasFit(req: Request): Promise<Response> {
2201
- const body = await readJson(req);
2202
- const nodeIds = Array.isArray(body.nodeIds)
2203
- ? body.nodeIds.filter((id): id is string => typeof id === 'string')
2204
- : undefined;
2205
- const result = fitCanvasView({
2206
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
2207
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2208
- ...(typeof body.padding === 'number' ? { padding: body.padding } : {}),
2209
- ...(typeof body.maxScale === 'number' ? { maxScale: body.maxScale } : {}),
2210
- ...(nodeIds ? { nodeIds } : {}),
2211
- });
2212
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: result.viewport });
2213
- return responseJson(result);
1540
+ return responseJson({ ok: true, theme: next });
2214
1541
  }
2215
1542
 
2216
- async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
2217
- const body = await readJson(req);
2218
- const title = typeof body.title === 'string' ? body.title.trim() : '';
2219
- const appTsx = typeof body.appTsx === 'string' ? body.appTsx : '';
2220
- if (!title || !appTsx) {
2221
- return responseJson({ ok: false, error: 'Missing required fields: title, appTsx.' }, 400);
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);
2222
1549
  }
2223
1550
 
2224
- const files: Record<string, string> = {};
2225
- if (body.files && typeof body.files === 'object' && !Array.isArray(body.files)) {
2226
- for (const [pathKey, value] of Object.entries(body.files as Record<string, unknown>)) {
2227
- if (typeof value === 'string') files[pathKey] = value;
2228
- }
2229
- }
2230
-
2231
- try {
2232
- const result = await buildWebArtifactOnCanvas({
2233
- title,
2234
- appTsx,
2235
- ...(typeof body.indexCss === 'string' ? { indexCss: body.indexCss } : {}),
2236
- ...(typeof body.mainTsx === 'string' ? { mainTsx: body.mainTsx } : {}),
2237
- ...(typeof body.indexHtml === 'string' ? { indexHtml: body.indexHtml } : {}),
2238
- ...(Object.keys(files).length > 0 ? { files } : {}),
2239
- ...(typeof body.projectPath === 'string'
2240
- ? { projectPath: resolveWorkspacePath(body.projectPath, activeWorkspaceRoot) }
2241
- : {}),
2242
- ...(typeof body.outputPath === 'string'
2243
- ? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
2244
- : {}),
2245
- // Script-path overrides are honored only when contained inside the
2246
- // workspace (enforced by resolveTrustedScriptPath in
2247
- // executeWebArtifactBuild), so they cannot point at an arbitrary host
2248
- // script for bash execution.
2249
- ...(typeof body.initScriptPath === 'string'
2250
- ? { initScriptPath: body.initScriptPath }
2251
- : {}),
2252
- ...(typeof body.bundleScriptPath === 'string'
2253
- ? { bundleScriptPath: body.bundleScriptPath }
2254
- : {}),
2255
- ...(Array.isArray(body.deps)
2256
- ? { deps: body.deps.filter((dep): dep is string => typeof dep === 'string') }
2257
- : {}),
2258
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
2259
- ...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
2260
- });
2261
-
2262
- return responseJson({
2263
- ok: true,
2264
- path: result.filePath,
2265
- bytes: result.fileSize,
2266
- projectPath: result.projectPath,
2267
- openedInCanvas: result.openedInCanvas,
2268
- startedAt: result.startedAt,
2269
- completedAt: result.completedAt,
2270
- durationMs: result.durationMs,
2271
- timeoutMs: result.timeoutMs,
2272
- // `id` is the canvas node id alias used by every other add-style
2273
- // response. It is only present when a canvas node was actually
2274
- // created (i.e. openInCanvas was not explicitly disabled). When
2275
- // there is no canvas node, the alias is intentionally omitted so
2276
- // consumers can `'id' in response` to detect the build-only case.
2277
- ...(typeof result.nodeId === 'string' ? { id: result.nodeId } : {}),
2278
- nodeId: result.nodeId,
2279
- url: result.url,
2280
- metadata: result.metadata,
2281
- logs: result.logs,
2282
- ...(body.includeLogs === true ? {
2283
- stdout: result.stdout,
2284
- stderr: result.stderr,
2285
- } : {}),
2286
- });
2287
- } catch (error) {
2288
- const message = error instanceof Error ? error.message : String(error);
2289
- return responseJson({ ok: false, error: message }, 400);
2290
- }
2291
- }
2292
-
2293
- function handleCanvasDescribeSchema(): Response {
2294
- return responseJson(describeCanvasSchema());
2295
- }
2296
-
2297
- async function handleCanvasValidateSpec(req: Request): Promise<Response> {
2298
- const body = await readJson(req);
2299
- const rawType = typeof body.type === 'string' ? body.type.trim() : '';
2300
- if (rawType !== 'json-render' && rawType !== 'graph' && rawType !== 'html-primitive') {
2301
- return responseJson({ ok: false, error: 'Validation type must be "json-render", "graph", or "html-primitive".' }, 400);
2302
- }
2303
-
2304
- try {
2305
- if (rawType === 'json-render') {
2306
- const rawSpec =
2307
- body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec)
2308
- ? body.spec
2309
- : body;
2310
- return responseJson(validateStructuredCanvasPayload({
2311
- type: 'json-render',
2312
- spec: rawSpec,
2313
- }));
2314
- }
2315
-
2316
- if (rawType === 'html-primitive') {
2317
- const kind = typeof body.kind === 'string'
2318
- ? body.kind
2319
- : typeof body.primitive === 'string'
2320
- ? body.primitive
2321
- : '';
2322
- const data = isRecord(body.data) ? body.data : {};
2323
- return responseJson(validateStructuredCanvasPayload({
2324
- type: 'html-primitive',
2325
- primitive: {
2326
- kind,
2327
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
2328
- data,
2329
- },
2330
- }));
2331
- }
2332
-
2333
- const data = parseGraphPayloadData(body.data);
2334
- if (!data) {
2335
- return responseJson({ ok: false, error: 'Graph validation requires a data array.' }, 400);
2336
- }
2337
-
2338
- const aggregate =
2339
- body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
2340
- ? body.aggregate
2341
- : undefined;
2342
-
2343
- return responseJson(validateStructuredCanvasPayload({
2344
- type: 'graph',
2345
- graph: {
2346
- title: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph',
2347
- graphType: typeof body.graphType === 'string'
2348
- ? body.graphType
2349
- : typeof body.typeName === 'string'
2350
- ? body.typeName
2351
- : 'line',
2352
- data,
2353
- ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
2354
- ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
2355
- ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
2356
- ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
2357
- ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
2358
- ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
2359
- ...(Array.isArray(body.metrics)
2360
- ? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
2361
- : {}),
2362
- ...(Array.isArray(body.series)
2363
- ? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
2364
- : {}),
2365
- ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
2366
- ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
2367
- ...(aggregate ? { aggregate } : {}),
2368
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
2369
- ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
2370
- ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
2371
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2372
- },
2373
- }));
2374
- } catch (error) {
2375
- const message = error instanceof Error ? error.message : String(error);
2376
- return responseJson({ ok: false, error: message, type: rawType }, 400);
2377
- }
2378
- }
2379
-
2380
- async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
2381
- const body = await readJson(req);
2382
- const title = typeof body.title === 'string' ? body.title.trim() : '';
2383
- const rawSpec =
2384
- body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
2385
- const geometry = resolveCreateGeometry(body);
2386
-
2387
- try {
2388
- const result = createCanvasJsonRenderNode({
2389
- ...(title ? { title } : {}),
2390
- spec: rawSpec,
2391
- ...(body.strictSize === true ? { strictSize: true } : {}),
2392
- ...geometry,
2393
- });
2394
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2395
- return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
2396
- } catch (error) {
2397
- const message = error instanceof Error ? error.message : String(error);
2398
- return responseJson({ ok: false, error: message }, 400);
2399
- }
2400
- }
2401
-
2402
- async function handleJsonRenderStream(req: Request): Promise<Response> {
2403
- const body = await readJson(req);
2404
- const patches = Array.isArray(body.patches) ? body.patches : [];
2405
- const done = body.done === true;
2406
- const geometry = resolveCreateGeometry(body);
2407
- try {
2408
- let nodeId = typeof body.nodeId === 'string' && body.nodeId ? body.nodeId : undefined;
2409
- let url = '';
2410
- if (!nodeId) {
2411
- const created = createCanvasStreamingJsonRenderNode({
2412
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
2413
- ...(body.strictSize === true ? { strictSize: true } : {}),
2414
- ...geometry,
2415
- });
2416
- nodeId = created.id;
2417
- url = created.url;
2418
- }
2419
- const result = appendCanvasJsonRenderStream(nodeId, patches, done);
2420
- if (!result.ok) return responseJson({ ok: false, error: result.error }, 400);
2421
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2422
- const node = canvasState.getNode(nodeId);
2423
- return responseJson({ id: nodeId, url: url || String(node?.data.url ?? ''), ...result });
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 handleCanvasAddGraph(req: Request): Promise<Response> {
2431
- const body = await readJson(req);
2432
- const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
2433
- const graphType = typeof body.graphType === 'string' ? body.graphType : typeof body.type === 'string' ? body.type : 'line';
2434
- const data = parseGraphPayloadData(body.data);
2435
- if (!data) {
2436
- return responseJson({ ok: false, error: 'Missing required field: data.' }, 400);
2437
- }
2438
-
2439
- try {
2440
- const aggregate =
2441
- body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
2442
- ? body.aggregate
2443
- : undefined;
2444
- const metrics = Array.isArray(body.metrics)
2445
- ? body.metrics.filter((m: unknown): m is string => typeof m === 'string')
2446
- : null;
2447
- const series = Array.isArray(body.series)
2448
- ? body.series.filter((s: unknown): s is string => typeof s === 'string')
2449
- : null;
2450
- const position = getRecord(body.position);
2451
- const size = getRecord(body.size);
2452
- const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
2453
- const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
2454
- const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
2455
- // Node FRAME height. `body.height` is the CHART plot height (passed through as
2456
- // `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
2457
- // `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
2458
- // field, the natural thing a caller reaches for. (With content-fit the node grows
2459
- // to the chart anyway; this just removes the silent "height ignored" surprise.)
2460
- const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
2461
- ?? pickPositiveNumber(body, 'heightPx')
2462
- ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2463
- const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
2464
- const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
2465
- const colorBy =
2466
- body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
2467
- ? body.colorBy
2468
- : undefined;
2469
- const highlight =
2470
- typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
2471
- ? body.highlight
2472
- : undefined;
2473
- const sort =
2474
- body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
2475
- const result = createCanvasGraphNode({
2476
- title,
2477
- graphType,
2478
- data,
2479
- ...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
2480
- ...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
2481
- ...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
2482
- ...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
2483
- ...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
2484
- ...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
2485
- ...(metrics ? { metrics } : {}),
2486
- ...(series ? { series } : {}),
2487
- ...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
2488
- ...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
2489
- ...(aggregate ? { aggregate } : {}),
2490
- ...(typeof body.color === 'string' ? { color: body.color } : {}),
2491
- ...(colorBy ? { colorBy } : {}),
2492
- ...(highlight !== undefined ? { highlight } : {}),
2493
- ...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
2494
- ...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
2495
- ...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
2496
- ...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
2497
- ...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
2498
- ...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
2499
- ...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
2500
- ...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
2501
- ...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
2502
- ...(sort ? { sort } : {}),
2503
- ...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
2504
- ...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
2505
- ...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
2506
- ...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
2507
- ...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
2508
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
2509
- ...(showLegend !== undefined ? { showLegend } : {}),
2510
- ...(showLabels !== undefined ? { showLabels } : {}),
2511
- ...(body.strictSize === true ? { strictSize: true } : {}),
2512
- ...(x !== undefined ? { x } : {}),
2513
- ...(y !== undefined ? { y } : {}),
2514
- ...(width !== undefined ? { width } : {}),
2515
- ...(nodeHeight !== undefined ? { heightPx: nodeHeight } : {}),
2516
- });
2517
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2518
- return responseJson({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec });
2519
- } catch (error) {
2520
- const message = error instanceof Error ? error.message : String(error);
2521
- return responseJson({ ok: false, error: message }, 400);
2522
- }
2523
- }
2524
-
2525
- async function handleCanvasBatch(req: Request): Promise<Response> {
2526
- // Accept both documented shapes: { operations: [...] } and a bare [...] array.
2527
- // Uses the array-preserving reader so the bare-array form isn't coerced to {}.
2528
- const body = await readJsonObjectOrArray(req);
2529
- const operations = Array.isArray(body)
2530
- ? body
2531
- : Array.isArray(body.operations) ? body.operations : [];
2532
- const normalized = operations
2533
- .filter((operation): operation is Record<string, unknown> => operation && typeof operation === 'object' && !Array.isArray(operation))
2534
- .map((operation) => ({
2535
- op: String(operation.op ?? ''),
2536
- ...(typeof operation.assign === 'string' ? { assign: operation.assign } : {}),
2537
- args: operation.args && typeof operation.args === 'object' && !Array.isArray(operation.args)
2538
- ? operation.args as Record<string, unknown>
2539
- : {},
2540
- }));
2541
- const result = await executeCanvasBatch(normalized);
2542
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
2543
- return responseJson(result, result.ok ? 200 : 400);
2544
- }
2545
-
2546
- function handleCanvasValidate(): Response {
2547
- return responseJson(validateCanvasLayout(canvasState.getLayout()));
2548
- }
2549
-
2550
- async function handleCanvasThemeUpdate(req: Request): Promise<Response> {
2551
- const body = await readJson(req);
2552
- const theme = normalizeCanvasTheme(body.theme, canvasState.theme);
2553
- const next = canvasState.setTheme(theme);
2554
- broadcastWorkbenchEvent('theme-changed', {
2555
- theme: next,
2556
- sessionId: primaryWorkbenchSessionId,
2557
- timestamp: new Date().toISOString(),
2558
- });
2559
- return responseJson({ ok: true, theme: next });
2560
- }
2561
-
2562
- async function handleJsonRenderView(url: URL): Promise<Response> {
2563
- const nodeId = url.searchParams.get('nodeId') ?? '';
2564
- if (!nodeId) return responseText('Missing nodeId', 400);
2565
- const node = canvasState.getNode(nodeId);
2566
- if (!node || (node.type !== 'json-render' && node.type !== 'graph')) {
2567
- return responseText('json-render node not found', 404);
2568
- }
2569
-
2570
- const rawSpec = node.data.spec;
2571
- if (!rawSpec || typeof rawSpec !== 'object') {
2572
- return responseText('json-render spec missing', 404);
1551
+ const rawSpec = node.data.spec;
1552
+ if (!rawSpec || typeof rawSpec !== 'object') {
1553
+ return responseText('json-render spec missing', 404);
2573
1554
  }
2574
1555
  const spec = normalizeGraphViewerSpec(
2575
1556
  { type: node.type, data: node.data },
@@ -2632,13 +1613,6 @@ function responseText(text: string, status = 400): Response {
2632
1613
  });
2633
1614
  }
2634
1615
 
2635
- function parsePositiveIntegerParam(value: string | null): number | undefined {
2636
- if (!value) return undefined;
2637
- const parsed = Number(value);
2638
- if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
2639
- return Math.floor(parsed);
2640
- }
2641
-
2642
1616
  function handleArtifactView(url: URL): Response {
2643
1617
  const pathLike = url.searchParams.get('path') ?? '';
2644
1618
  const safePath = resolveWorkspaceArtifactPath(pathLike);
@@ -2827,229 +1801,6 @@ function handleRead(pathLike: string): Response {
2827
1801
  });
2828
1802
  }
2829
1803
 
2830
- function randomExtAppToolCallId(): string {
2831
- return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2832
- }
2833
-
2834
- function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
2835
- if (!node || node.type !== 'mcp-app') return null;
2836
- const sessionId = node.data.appSessionId;
2837
- return typeof sessionId === 'string' && sessionId.trim().length > 0 ? sessionId : null;
2838
- }
2839
-
2840
- function closeNodeAppSession(node: CanvasNodeState | undefined): void {
2841
- const sessionId = nodeAppSessionId(node);
2842
- if (sessionId) closeMcpAppSession(sessionId);
2843
- }
2844
-
2845
- function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
2846
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
2847
- const entries = Object.entries(value)
2848
- .filter((entry): entry is [string, string] => typeof entry[1] === 'string')
2849
- .map(([key, text]) => [key, text.trim()] as const)
2850
- .filter(([, text]) => text.length > 0);
2851
- return entries.length > 0 ? Object.fromEntries(entries) : undefined;
2852
- }
2853
-
2854
- function parseExternalMcpTransportConfig(body: Record<string, unknown>): ExternalMcpTransportConfig | null {
2855
- const transport = body.transport;
2856
- if (!transport || typeof transport !== 'object' || Array.isArray(transport)) return null;
2857
- const transportRecord = transport as Record<string, unknown>;
2858
-
2859
- const type = typeof transportRecord.type === 'string' ? transportRecord.type : '';
2860
- if (type === 'http') {
2861
- const url = typeof transportRecord.url === 'string' ? transportRecord.url.trim() : '';
2862
- if (!url) return null;
2863
- const headers = normalizeStringRecord(transportRecord.headers);
2864
- return {
2865
- type: 'http',
2866
- url,
2867
- ...(headers ? { headers } : {}),
2868
- };
2869
- }
2870
-
2871
- if (type === 'stdio') {
2872
- const command = typeof transportRecord.command === 'string' ? transportRecord.command.trim() : '';
2873
- if (!command) return null;
2874
- const env = normalizeStringRecord(transportRecord.env);
2875
- return {
2876
- type: 'stdio',
2877
- command,
2878
- ...(Array.isArray(transportRecord.args)
2879
- ? { args: transportRecord.args.filter((value: unknown): value is string => typeof value === 'string') }
2880
- : {}),
2881
- ...(typeof transportRecord.cwd === 'string' && transportRecord.cwd.trim().length > 0 ? { cwd: transportRecord.cwd } : {}),
2882
- ...(env ? { env } : {}),
2883
- };
2884
- }
2885
-
2886
- return null;
2887
- }
2888
-
2889
- interface RunAndEmitOpenMcpAppParams {
2890
- transport: ExternalMcpTransportConfig;
2891
- toolName: string;
2892
- toolArguments?: Record<string, unknown>;
2893
- nodeId?: string;
2894
- serverName?: string;
2895
- title?: string;
2896
- x?: number;
2897
- y?: number;
2898
- width?: number;
2899
- height?: number;
2900
- timeoutMs?: number;
2901
- }
2902
-
2903
- async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise<Response> {
2904
- try {
2905
- const targetNode = params.nodeId ? canvasState.getNode(params.nodeId) : undefined;
2906
- if (params.nodeId && !targetNode) {
2907
- return responseJson({ ok: false, error: `Node "${params.nodeId}" not found.` }, 404);
2908
- }
2909
- if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
2910
- return responseJson({ ok: false, error: `Node "${params.nodeId}" is not an external app node.` }, 400);
2911
- }
2912
-
2913
- const opened = await openMcpApp({
2914
- transport: params.transport,
2915
- toolName: params.toolName,
2916
- ...(params.toolArguments ? { toolArguments: params.toolArguments } : {}),
2917
- ...(params.serverName ? { serverName: params.serverName } : {}),
2918
- ...(typeof params.timeoutMs === 'number' ? { timeoutMs: params.timeoutMs } : {}),
2919
- });
2920
-
2921
- const toolCallId = randomExtAppToolCallId();
2922
- if (params.nodeId) closeNodeAppSession(targetNode);
2923
- const nodeIdSeed = params.nodeId ?? (toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`);
2924
- const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
2925
- ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
2926
- : opened.toolResult;
2927
- const nodeTitle = params.title
2928
- ?? (typeof targetNode?.data.title === 'string' ? targetNode.data.title : undefined)
2929
- ?? opened.tool.title
2930
- ?? opened.tool.name;
2931
-
2932
- emitPrimaryWorkbenchEvent('ext-app-open', {
2933
- toolCallId,
2934
- nodeId: nodeIdSeed,
2935
- title: nodeTitle,
2936
- html: opened.html,
2937
- toolInput: opened.toolInput,
2938
- serverName: opened.serverName,
2939
- toolName: opened.toolName,
2940
- appSessionId: opened.sessionId,
2941
- transportConfig: params.transport,
2942
- resourceUri: opened.resourceUri,
2943
- toolDefinition: opened.tool,
2944
- sessionStatus: 'ready',
2945
- sessionError: null,
2946
- ...(opened.resourceMeta ? { resourceMeta: opened.resourceMeta } : {}),
2947
- ...(typeof params.x === 'number' ? { x: params.x } : {}),
2948
- ...(typeof params.y === 'number' ? { y: params.y } : {}),
2949
- ...(typeof params.width === 'number' ? { width: params.width } : {}),
2950
- ...(typeof params.height === 'number' ? { height: params.height } : {}),
2951
- });
2952
- emitPrimaryWorkbenchEvent('ext-app-result', {
2953
- toolCallId,
2954
- nodeId: nodeIdSeed,
2955
- serverName: opened.serverName,
2956
- toolName: opened.toolName,
2957
- success: toolResult.isError !== true,
2958
- result: toolResult,
2959
- });
2960
- const nodeId = params.nodeId ?? findCanvasExtAppNodeId(toolCallId);
2961
-
2962
- return responseJson({
2963
- ok: true,
2964
- ...(nodeId ? { id: nodeId } : {}),
2965
- nodeId,
2966
- toolCallId,
2967
- sessionId: opened.sessionId,
2968
- resourceUri: opened.resourceUri,
2969
- serverName: opened.serverName,
2970
- toolName: opened.toolName,
2971
- });
2972
- } catch (error) {
2973
- return responseJson({
2974
- ok: false,
2975
- error: error instanceof Error ? error.message : String(error),
2976
- }, 400);
2977
- }
2978
- }
2979
-
2980
- async function handleCanvasOpenMcpApp(req: Request): Promise<Response> {
2981
- const body = await readJson(req);
2982
- const transport = parseExternalMcpTransportConfig(body);
2983
- const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
2984
- if (!transport || !toolName) {
2985
- return responseJson({ ok: false, error: 'Missing valid transport or toolName.' }, 400);
2986
- }
2987
-
2988
- const toolArguments =
2989
- body.toolArguments && typeof body.toolArguments === 'object' && !Array.isArray(body.toolArguments)
2990
- ? body.toolArguments as Record<string, unknown>
2991
- : undefined;
2992
-
2993
- const requestedTitle = typeof body.title === 'string' && body.title.trim().length > 0
2994
- ? body.title.trim()
2995
- : undefined;
2996
- const requestedServerName = typeof body.serverName === 'string' && body.serverName.trim().length > 0
2997
- ? body.serverName.trim()
2998
- : undefined;
2999
- const requestedNodeId = typeof body.nodeId === 'string' && body.nodeId.trim().length > 0
3000
- ? body.nodeId.trim()
3001
- : undefined;
3002
-
3003
- return runAndEmitOpenMcpApp({
3004
- transport,
3005
- toolName,
3006
- ...(toolArguments ? { toolArguments } : {}),
3007
- ...(requestedNodeId ? { nodeId: requestedNodeId } : {}),
3008
- ...(requestedServerName ? { serverName: requestedServerName } : {}),
3009
- ...(requestedTitle ? { title: requestedTitle } : {}),
3010
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
3011
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
3012
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
3013
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
3014
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
3015
- });
3016
- }
3017
-
3018
- async function handleCanvasAddDiagram(req: Request): Promise<Response> {
3019
- const body = await readJson(req);
3020
- let built;
3021
- try {
3022
- built = buildExcalidrawOpenMcpAppInput({
3023
- elements: body.elements,
3024
- ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
3025
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
3026
- ...(typeof body.x === 'number' ? { x: body.x } : {}),
3027
- ...(typeof body.y === 'number' ? { y: body.y } : {}),
3028
- ...(typeof body.width === 'number' ? { width: body.width } : {}),
3029
- ...(typeof body.height === 'number' ? { height: body.height } : {}),
3030
- ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
3031
- });
3032
- } catch (error) {
3033
- return responseJson({
3034
- ok: false,
3035
- error: error instanceof Error ? error.message : String(error),
3036
- }, 400);
3037
- }
3038
- return runAndEmitOpenMcpApp({
3039
- transport: built.transport,
3040
- toolName: built.toolName,
3041
- toolArguments: built.toolArguments,
3042
- serverName: built.serverName,
3043
- ...(built.nodeId ? { nodeId: built.nodeId } : {}),
3044
- ...(built.title ? { title: built.title } : {}),
3045
- ...(typeof built.x === 'number' ? { x: built.x } : {}),
3046
- ...(typeof built.y === 'number' ? { y: built.y } : {}),
3047
- ...(typeof built.width === 'number' ? { width: built.width } : {}),
3048
- ...(typeof built.height === 'number' ? { height: built.height } : {}),
3049
- ...(typeof built.timeoutMs === 'number' ? { timeoutMs: built.timeoutMs } : {}),
3050
- });
3051
- }
3052
-
3053
1804
  async function handleExtAppCallTool(req: Request): Promise<Response> {
3054
1805
  const body = await readJson(req);
3055
1806
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
@@ -3251,120 +2002,15 @@ function handleWorkbenchState(): Response {
3251
2002
  });
3252
2003
  }
3253
2004
 
3254
- function parseCanvasAutomationWebViewRequestBody(
3255
- body: Record<string, unknown>,
3256
- ): CanvasAutomationWebViewOptions {
3257
- const backendValue = typeof body.backend === 'string' ? body.backend.trim() : '';
3258
- const backend =
3259
- backendValue === 'chrome' || backendValue === 'webkit'
3260
- ? backendValue
3261
- : undefined;
3262
-
3263
- const width = typeof body.width === 'number' ? body.width : undefined;
3264
- const height = typeof body.height === 'number' ? body.height : undefined;
3265
- const chromePath = typeof body.chromePath === 'string' ? body.chromePath : undefined;
3266
- const dataStoreDir = typeof body.dataStoreDir === 'string' ? body.dataStoreDir : undefined;
3267
- const chromeArgv = Array.isArray(body.chromeArgv)
3268
- ? body.chromeArgv.filter((value): value is string => typeof value === 'string')
3269
- : undefined;
3270
-
3271
- return {
3272
- ...(backend ? { backend } : {}),
3273
- ...(width !== undefined ? { width } : {}),
3274
- ...(height !== undefined ? { height } : {}),
3275
- ...(chromePath ? { chromePath } : {}),
3276
- ...(chromeArgv ? { chromeArgv } : {}),
3277
- ...(dataStoreDir ? { dataStoreDir } : {}),
3278
- };
3279
- }
3280
-
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).
3281
2010
  function currentWorkbenchUrl(): string | null {
3282
2011
  return server && typeof server.port === 'number' ? `${loopbackBaseUrl(server.port)}/workbench` : null;
3283
2012
  }
3284
2013
 
3285
- function handleWorkbenchWebViewStatus(): Response {
3286
- return responseJson(getCanvasAutomationWebViewStatus());
3287
- }
3288
-
3289
- async function handleWorkbenchWebViewStart(req: Request): Promise<Response> {
3290
- const url = currentWorkbenchUrl();
3291
- if (!url) {
3292
- return responseJson({ ok: false, error: 'Canvas server is not running.' }, 503);
3293
- }
3294
-
3295
- const body = await readJson(req);
3296
- const options = parseCanvasAutomationWebViewRequestBody(body);
3297
-
3298
- try {
3299
- const webview = await startCanvasAutomationWebView(url, options);
3300
- return responseJson({ ok: true, webview });
3301
- } catch (error) {
3302
- const message = error instanceof Error ? error.message : String(error);
3303
- const status = hasCanvasAutomationWebViewSupport() ? 500 : 501;
3304
- return responseJson({
3305
- ok: false,
3306
- error: message,
3307
- webview: getCanvasAutomationWebViewStatus(),
3308
- }, status);
3309
- }
3310
- }
3311
-
3312
- async function handleWorkbenchWebViewStop(): Promise<Response> {
3313
- try {
3314
- const stopped = await stopCanvasAutomationWebView();
3315
- return responseJson({
3316
- ok: true,
3317
- stopped,
3318
- webview: getCanvasAutomationWebViewStatus(),
3319
- });
3320
- } catch (error) {
3321
- const message = error instanceof Error ? error.message : String(error);
3322
- return responseJson({
3323
- ok: false,
3324
- error: message,
3325
- webview: getCanvasAutomationWebViewStatus(),
3326
- }, 500);
3327
- }
3328
- }
3329
-
3330
- async function handleWorkbenchWebViewEvaluate(req: Request): Promise<Response> {
3331
- const body = await readJson(req);
3332
- const expression = typeof body.expression === 'string' ? body.expression.trim() : '';
3333
- const script = typeof body.script === 'string' ? body.script.trim() : '';
3334
- if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
3335
- return responseJson({
3336
- ok: false,
3337
- error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an async IIFE).',
3338
- }, 400);
3339
- }
3340
- const source = script ? wrapCanvasAutomationScript(script) : expression;
3341
-
3342
- try {
3343
- const value = await evaluateCanvasAutomationWebView(source);
3344
- return responseJson({ ok: true, value });
3345
- } catch (error) {
3346
- const message = error instanceof Error ? error.message : String(error);
3347
- return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
3348
- }
3349
- }
3350
-
3351
- async function handleWorkbenchWebViewResize(req: Request): Promise<Response> {
3352
- const body = await readJson(req);
3353
- const width = typeof body.width === 'number' ? body.width : NaN;
3354
- const height = typeof body.height === 'number' ? body.height : NaN;
3355
- if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
3356
- return responseJson({ ok: false, error: 'Missing required positive numeric fields: width, height.' }, 400);
3357
- }
3358
-
3359
- try {
3360
- const webview = await resizeCanvasAutomationWebView(width, height);
3361
- return responseJson({ ok: true, webview });
3362
- } catch (error) {
3363
- const message = error instanceof Error ? error.message : String(error);
3364
- return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
3365
- }
3366
- }
3367
-
3368
2014
  async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
3369
2015
  const body = await readJson(req);
3370
2016
  const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
@@ -3838,43 +2484,6 @@ async function handleCanvasPrompt(req: Request): Promise<Response> {
3838
2484
  return responseJson({ ok: true, nodeId });
3839
2485
  }
3840
2486
 
3841
- async function handleSnapshotSave(req: Request): Promise<Response> {
3842
- const body = await readJson(req);
3843
- const name = typeof body.name === 'string' ? body.name.trim() : '';
3844
- if (!name) return responseText('Missing snapshot name', 400);
3845
- const snapshot = saveCanvasSnapshot(name);
3846
- if (!snapshot) return responseText('Failed to save snapshot', 500);
3847
- return responseJson({ ok: true, id: snapshot.id, snapshot });
3848
- }
3849
-
3850
- async function handleSnapshotGc(req: Request): Promise<Response> {
3851
- const body = await readJson(req);
3852
- const keepValue = body.keep;
3853
- const keep = typeof keepValue === 'number'
3854
- ? keepValue
3855
- : typeof keepValue === 'string'
3856
- ? Number(keepValue)
3857
- : undefined;
3858
- const dryRun = body.dryRun === true || body['dry-run'] === true;
3859
- return responseJson(gcCanvasSnapshots({ keep, dryRun }));
3860
- }
3861
-
3862
- async function handleContextPinsUpdate(req: Request): Promise<Response> {
3863
- const body = await readJson(req);
3864
- const MAX_PINS = 20;
3865
- const nodeIds = Array.isArray(body.nodeIds)
3866
- ? (body.nodeIds.filter((id: unknown) => typeof id === 'string') as string[]).slice(0, MAX_PINS)
3867
- : [];
3868
- const result = setCanvasContextPins(nodeIds, 'set');
3869
- broadcastWorkbenchEvent('context-pins-changed', {
3870
- count: result.count,
3871
- nodeIds: result.nodeIds,
3872
- sessionId: primaryWorkbenchSessionId,
3873
- timestamp: new Date().toISOString(),
3874
- });
3875
- return responseJson({ ok: true, count: result.count });
3876
- }
3877
-
3878
2487
  function handleGetPinnedContext(): Response {
3879
2488
  const pinnedIds = Array.from(canvasState.contextPinnedNodeIds);
3880
2489
  const preamble = pinnedIds.length > 0 ? buildSelectionContextPreamble(pinnedIds) : '';
@@ -3908,14 +2517,95 @@ function normalizeAxSource(value: unknown, fallback: PmxAxSource): PmxAxSource {
3908
2517
  : fallback;
3909
2518
  }
3910
2519
 
3911
- function handleGetAxState(): Response {
3912
- return responseJson({ ok: true, state: canvasState.getAxState() });
2520
+ function handleGetAxContext(url: URL): Response {
2521
+ // Optional ?consumer= filters the compact `delivery` lead block (loop-safe — a
2522
+ // consumer never sees steering/activity it originated), so a host adapter can
2523
+ // inject its own un-truncated pending block per turn (report #54 hardening).
2524
+ const consumer = url.searchParams.get('consumer') ?? undefined;
2525
+ return responseJson(buildCanvasAxContext(consumer));
3913
2526
  }
3914
2527
 
3915
- function handleGetAxContext(): Response {
3916
- return responseJson(buildCanvasAxContext());
2528
+ function isReviewSeverity(v: unknown): v is PmxAxReviewSeverity {
2529
+ return v === 'info' || v === 'warning' || v === 'error';
2530
+ }
2531
+ function isReviewKind(v: unknown): v is PmxAxReviewKind {
2532
+ return v === 'comment' || v === 'finding';
2533
+ }
2534
+ function isReviewAnchor(v: unknown): v is PmxAxReviewAnchorType {
2535
+ return v === 'node' || v === 'file' || v === 'region';
2536
+ }
2537
+
2538
+ // Validate untrusted activity `reactions` from an HTTP body into the typed override
2539
+ // shape ingestActivity expects. `false` suppresses a default reaction; an object
2540
+ // overrides its fields (invalid fields are dropped, not stored raw).
2541
+ function normalizeActivityReactions(input: Record<string, unknown>): {
2542
+ workItem?: false | { status?: PmxAxWorkItemStatus; detail?: string | null };
2543
+ evidence?: false | { kind?: PmxAxEvidenceKind; body?: string | null };
2544
+ review?: false | { severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind; anchorType?: PmxAxReviewAnchorType; nodeId?: string | null };
2545
+ } {
2546
+ const out: ReturnType<typeof normalizeActivityReactions> = {};
2547
+ if (input.workItem === false) out.workItem = false;
2548
+ else if (isRecord(input.workItem)) {
2549
+ const status = normalizeAxWorkItemStatus(input.workItem.status);
2550
+ out.workItem = {
2551
+ ...(status ? { status } : {}),
2552
+ ...(typeof input.workItem.detail === 'string' ? { detail: input.workItem.detail } : {}),
2553
+ };
2554
+ }
2555
+ if (input.evidence === false) out.evidence = false;
2556
+ else if (isRecord(input.evidence)) {
2557
+ out.evidence = {
2558
+ ...(isAxEvidenceKind(input.evidence.kind) ? { kind: input.evidence.kind } : {}),
2559
+ ...(typeof input.evidence.body === 'string' ? { body: input.evidence.body } : {}),
2560
+ };
2561
+ }
2562
+ if (input.review === false) out.review = false;
2563
+ else if (isRecord(input.review)) {
2564
+ out.review = {
2565
+ ...(isReviewSeverity(input.review.severity) ? { severity: input.review.severity } : {}),
2566
+ ...(isReviewKind(input.review.kind) ? { kind: input.review.kind } : {}),
2567
+ ...(isReviewAnchor(input.review.anchorType) ? { anchorType: input.review.anchorType } : {}),
2568
+ ...(typeof input.review.nodeId === 'string' ? { nodeId: input.review.nodeId } : {}),
2569
+ };
2570
+ }
2571
+ return out;
3917
2572
  }
3918
2573
 
2574
+ // Report primitive A: ingest a harness-forwarded agent activity; the board auto-reacts.
2575
+ async function handleAxActivityIngest(req: Request): Promise<Response> {
2576
+ const body = await readJson(req);
2577
+ if (!isAxActivityKind(body.kind)) {
2578
+ return responseJson({ ok: false, error: "activity requires a valid 'kind': one of tool-start, tool-result, failure, error, session-start, session-end, command, note." }, 400);
2579
+ }
2580
+ if (typeof body.title !== 'string' || !body.title.trim()) {
2581
+ return responseJson({ ok: false, error: 'activity requires a title.' }, 400);
2582
+ }
2583
+ const result = canvasState.ingestActivity(
2584
+ {
2585
+ kind: body.kind,
2586
+ title: body.title,
2587
+ ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
2588
+ ...(body.outcome === 'success' || body.outcome === 'failure' ? { outcome: body.outcome } : {}),
2589
+ ...(typeof body.ref === 'string' ? { ref: body.ref } : {}),
2590
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
2591
+ ...(isRecord(body.data) ? { data: body.data } : {}),
2592
+ ...(isRecord(body.reactions) ? { reactions: normalizeActivityReactions(body.reactions) } : {}),
2593
+ },
2594
+ { source: normalizeAxSource(body.source, 'api') },
2595
+ );
2596
+ const meta = { sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() };
2597
+ broadcastWorkbenchEvent('ax-event-created', { event: result.event, ...meta });
2598
+ if (result.workItem) broadcastWorkbenchEvent('ax-state-changed', { workItem: result.workItem, ...meta });
2599
+ if (result.evidence) broadcastWorkbenchEvent('ax-event-created', { evidence: result.evidence, ...meta });
2600
+ if (result.review) broadcastWorkbenchEvent('ax-state-changed', { reviewAnnotation: result.review, ...meta });
2601
+ return responseJson({ ok: true, ...result });
2602
+ }
2603
+
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.
2608
+
3919
2609
  // Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
3920
2610
  // The client fetches this and pushes it to surfaces over the ax-update channel.
3921
2611
  function handleGetAxSurfaceSnapshot(): Response {
@@ -3961,134 +2651,32 @@ async function handleAxInteraction(req: Request): Promise<Response> {
3961
2651
  return responseJson(result, result.ok ? 200 : result.status);
3962
2652
  }
3963
2653
 
3964
- function handleAxDeliveryPending(url: URL): Response {
3965
- const consumer = url.searchParams.get('consumer') ?? undefined;
3966
- const limitRaw = Number(url.searchParams.get('limit') ?? '');
3967
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? limitRaw : undefined;
3968
- const pending = canvasState.getPendingSteering({
3969
- ...(consumer ? { consumer } : {}),
3970
- ...(limit ? { limit } : {}),
3971
- });
3972
- return responseJson({ ok: true, pending });
3973
- }
3974
-
3975
- function handleAxDeliveryMark(id: string): Response {
3976
- const delivered = canvasState.markSteeringDelivered(id);
3977
- if (delivered) {
3978
- broadcastWorkbenchEvent('ax-event-created', {
3979
- steeringDelivered: id,
3980
- sessionId: primaryWorkbenchSessionId,
3981
- timestamp: new Date().toISOString(),
3982
- });
3983
- }
3984
- return responseJson({ ok: true, delivered });
3985
- }
2654
+ // handleAxDeliveryPending / handleAxDeliveryMark migrated to the operation
2655
+ // registry (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
3986
2656
 
3987
2657
  function handleAxElicitationList(): Response {
3988
2658
  return responseJson({ ok: true, elicitations: canvasState.getElicitations() });
3989
2659
  }
3990
2660
 
3991
- async function handleAxElicitationRequest(req: Request): Promise<Response> {
3992
- const body = await readJson(req);
3993
- if (typeof body.prompt !== 'string' || !body.prompt.trim()) {
3994
- return responseJson({ ok: false, error: 'elicitation requires a prompt.' }, 400);
3995
- }
3996
- const elicitation = canvasState.requestElicitation(
3997
- {
3998
- prompt: body.prompt,
3999
- ...(Array.isArray(body.fields) ? { fields: body.fields.filter((f: unknown): f is string => typeof f === 'string') } : {}),
4000
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4001
- },
4002
- { source: normalizeAxSource(body.source, 'api') },
4003
- );
4004
- broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4005
- return responseJson({ ok: true, elicitation });
4006
- }
4007
-
4008
- async function handleAxElicitationRespond(req: Request, id: string): Promise<Response> {
4009
- const body = await readJson(req);
4010
- const response = isRecord(body.response) ? body.response : {};
4011
- const elicitation = canvasState.respondElicitation(id, response, { source: normalizeAxSource(body.source, 'api') });
4012
- if (!elicitation) return responseJson({ ok: false, error: 'elicitation not found or already answered.' }, 404);
4013
- broadcastWorkbenchEvent('ax-state-changed', { elicitation, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4014
- return responseJson({ ok: true, elicitation });
4015
- }
2661
+ // handleAxElicitationRequest / handleAxElicitationRespond migrated to the
2662
+ // operation registry (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4016
2663
 
4017
2664
  function handleAxModeList(): Response {
4018
2665
  return responseJson({ ok: true, modeRequests: canvasState.getModeRequests() });
4019
2666
  }
4020
2667
 
4021
- async function handleAxModeRequest(req: Request): Promise<Response> {
4022
- const body = await readJson(req);
4023
- if (body.mode !== 'plan' && body.mode !== 'execute' && body.mode !== 'autonomous') {
4024
- return responseJson({ ok: false, error: 'mode request requires mode plan|execute|autonomous.' }, 400);
4025
- }
4026
- const modeRequest = canvasState.requestMode(
4027
- {
4028
- mode: body.mode,
4029
- ...(typeof body.reason === 'string' ? { reason: body.reason } : {}),
4030
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4031
- },
4032
- { source: normalizeAxSource(body.source, 'api') },
4033
- );
4034
- broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4035
- return responseJson({ ok: true, modeRequest });
4036
- }
4037
-
4038
- async function handleAxModeResolve(req: Request, id: string): Promise<Response> {
4039
- const body = await readJson(req);
4040
- if (body.decision !== 'approved' && body.decision !== 'rejected') {
4041
- return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
4042
- }
4043
- const modeRequest = canvasState.resolveModeRequest(id, body.decision, {
4044
- ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
4045
- source: normalizeAxSource(body.source, 'api'),
4046
- });
4047
- if (!modeRequest) return responseJson({ ok: false, error: 'mode request not found or already resolved.' }, 404);
4048
- broadcastWorkbenchEvent('ax-state-changed', { modeRequest, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4049
- return responseJson({ ok: true, modeRequest });
4050
- }
4051
-
4052
- function handleAxCommandList(): Response {
4053
- return responseJson({ ok: true, commands: canvasState.getCommandRegistry() });
4054
- }
4055
-
4056
- async function handleAxCommandInvoke(req: Request): Promise<Response> {
4057
- const body = await readJson(req);
4058
- if (typeof body.name !== 'string') {
4059
- return responseJson({ ok: false, error: 'command requires a name.' }, 400);
4060
- }
4061
- const event = canvasState.invokeCommand(body.name, isRecord(body.args) ? body.args : null, { source: normalizeAxSource(body.source, 'api') });
4062
- if (!event) return responseJson({ ok: false, error: `Unknown command "${body.name}".` }, 400);
4063
- broadcastWorkbenchEvent('ax-event-created', { event, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4064
- return responseJson({ ok: true, event });
4065
- }
4066
-
4067
- function handleAxPolicyGet(): Response {
4068
- return responseJson({ ok: true, policy: canvasState.getPolicy() });
4069
- }
4070
-
4071
- async function handleAxPolicySet(req: Request): Promise<Response> {
4072
- const body = await readJson(req);
4073
- const patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> } = {};
4074
- if (isRecord(body.tools)) patch.tools = body.tools as Partial<PmxAxPolicy['tools']>;
4075
- if (isRecord(body.prompt)) patch.prompt = body.prompt as Partial<PmxAxPolicy['prompt']>;
4076
- const policy = canvasState.setPolicy(patch, { source: normalizeAxSource(body.source, 'api') });
4077
- broadcastWorkbenchEvent('ax-state-changed', { policy, sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() });
4078
- return responseJson({ ok: true, policy });
2668
+ // handleAxModeRequest / handleAxModeResolve migrated to the operation registry
2669
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
2670
+
2671
+ function handleAxCommandList(): Response {
2672
+ return responseJson({ ok: true, commands: canvasState.getCommandRegistry() });
4079
2673
  }
4080
2674
 
4081
- async function handleAxFocusUpdate(req: Request): Promise<Response> {
4082
- const body = await readJson(req);
4083
- const nodeIds = normalizeAxNodeIds(body.nodeIds);
4084
- const source = normalizeAxSource(body.source, 'api');
4085
- const focus = canvasState.setAxFocus(nodeIds, { source });
4086
- broadcastWorkbenchEvent('ax-state-changed', {
4087
- focus,
4088
- sessionId: primaryWorkbenchSessionId,
4089
- timestamp: new Date().toISOString(),
4090
- });
4091
- return responseJson({ ok: true, focus });
2675
+ // handleAxCommandInvoke migrated to the operation registry (plan-007 Slice B
2676
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
2677
+
2678
+ function handleAxPolicyGet(): Response {
2679
+ return responseJson({ ok: true, policy: canvasState.getPolicy() });
4092
2680
  }
4093
2681
 
4094
2682
  async function handleAxStatePatch(req: Request): Promise<Response> {
@@ -4108,52 +2696,8 @@ async function handleAxStatePatch(req: Request): Promise<Response> {
4108
2696
  return responseJson({ ok: true, state: canvasState.getAxState() });
4109
2697
  }
4110
2698
 
4111
- async function handleAxEventAdd(req: Request): Promise<Response> {
4112
- const body = await readJson(req);
4113
- if (!isAxEventKind(body.kind) || typeof body.summary !== 'string') {
4114
- return responseJson({ ok: false, error: 'event requires kind and summary.' }, 400);
4115
- }
4116
- const event = canvasState.recordAxEvent(
4117
- {
4118
- kind: body.kind,
4119
- summary: body.summary,
4120
- detail: typeof body.detail === 'string' ? body.detail : null,
4121
- nodeIds: normalizeAxNodeIds(body.nodeIds),
4122
- data: isRecord(body.data) ? body.data : null,
4123
- },
4124
- { source: normalizeAxSource(body.source, 'api') },
4125
- );
4126
- broadcastWorkbenchEvent('ax-event-created', {
4127
- event,
4128
- sessionId: primaryWorkbenchSessionId,
4129
- timestamp: new Date().toISOString(),
4130
- });
4131
- return responseJson({ ok: true, event });
4132
- }
4133
-
4134
- async function handleAxSteer(req: Request): Promise<Response> {
4135
- const body = await readJson(req);
4136
- if (typeof body.message !== 'string' || !body.message.trim()) {
4137
- return responseJson({ ok: false, error: 'steer requires a non-empty message.' }, 400);
4138
- }
4139
- const steering = canvasState.recordSteeringMessage(body.message, {
4140
- source: normalizeAxSource(body.source, 'api'),
4141
- });
4142
- broadcastWorkbenchEvent('ax-event-created', {
4143
- steering,
4144
- sessionId: primaryWorkbenchSessionId,
4145
- timestamp: new Date().toISOString(),
4146
- });
4147
- return responseJson({ ok: true, steering });
4148
- }
4149
-
4150
- function handleAxTimelineGet(url: URL): Response {
4151
- const limit = Number(url.searchParams.get('limit') ?? '');
4152
- return responseJson({
4153
- ok: true,
4154
- ...canvasState.getAxTimeline(Number.isFinite(limit) && limit > 0 ? { limit } : {}),
4155
- });
4156
- }
2699
+ // handleAxEventAdd / handleAxSteer / handleAxTimelineGet migrated to the
2700
+ // operation registry (plan-007 Slice B wave 3): src/server/operations/ops/ax-timeline.ts.
4157
2701
 
4158
2702
  const AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
4159
2703
 
@@ -4167,225 +2711,33 @@ function handleAxWorkList(): Response {
4167
2711
  return responseJson({ ok: true, workItems: canvasState.getWorkItems() });
4168
2712
  }
4169
2713
 
4170
- async function handleAxWorkAdd(req: Request): Promise<Response> {
4171
- const body = await readJson(req);
4172
- if (typeof body.title !== 'string' || !body.title.trim()) {
4173
- return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
4174
- }
4175
- const status = normalizeAxWorkItemStatus(body.status);
4176
- const workItem = canvasState.addWorkItem(
4177
- {
4178
- title: body.title,
4179
- ...(status ? { status } : {}),
4180
- ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
4181
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4182
- },
4183
- { source: normalizeAxSource(body.source, 'api') },
4184
- );
4185
- broadcastWorkbenchEvent('ax-state-changed', {
4186
- workItem,
4187
- sessionId: primaryWorkbenchSessionId,
4188
- timestamp: new Date().toISOString(),
4189
- });
4190
- return responseJson({ ok: true, workItem });
4191
- }
4192
-
4193
- async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
4194
- const body = await readJson(req);
4195
- const status = normalizeAxWorkItemStatus(body.status);
4196
- const workItem = canvasState.updateWorkItem(
4197
- id,
4198
- {
4199
- ...(typeof body.title === 'string' ? { title: body.title } : {}),
4200
- ...(status ? { status } : {}),
4201
- ...(typeof body.detail === 'string' || body.detail === null ? { detail: body.detail as string | null } : {}),
4202
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4203
- },
4204
- { source: normalizeAxSource(body.source, 'api') },
4205
- );
4206
- if (!workItem) return responseJson({ ok: false, error: 'work item not found.' }, 404);
4207
- broadcastWorkbenchEvent('ax-state-changed', {
4208
- workItem,
4209
- sessionId: primaryWorkbenchSessionId,
4210
- timestamp: new Date().toISOString(),
4211
- });
4212
- return responseJson({ ok: true, workItem });
4213
- }
2714
+ // handleAxWorkAdd / handleAxWorkUpdate migrated to the operation registry
2715
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4214
2716
 
4215
2717
  function handleAxApprovalList(): Response {
4216
2718
  return responseJson({ ok: true, approvalGates: canvasState.getApprovalGates() });
4217
2719
  }
4218
2720
 
4219
- async function handleAxApprovalRequest(req: Request): Promise<Response> {
4220
- const body = await readJson(req);
4221
- if (typeof body.title !== 'string' || !body.title.trim()) {
4222
- return responseJson({ ok: false, error: 'approval request requires a title.' }, 400);
4223
- }
4224
- const approvalGate = canvasState.requestApproval(
4225
- {
4226
- title: body.title,
4227
- ...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
4228
- ...(typeof body.action === 'string' ? { action: body.action } : {}),
4229
- ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4230
- },
4231
- { source: normalizeAxSource(body.source, 'api') },
4232
- );
4233
- broadcastWorkbenchEvent('ax-state-changed', {
4234
- approvalGate,
4235
- sessionId: primaryWorkbenchSessionId,
4236
- timestamp: new Date().toISOString(),
4237
- });
4238
- return responseJson({ ok: true, approvalGate });
4239
- }
4240
-
4241
- async function handleAxApprovalResolve(req: Request, id: string): Promise<Response> {
4242
- const body = await readJson(req);
4243
- if (body.decision !== 'approved' && body.decision !== 'rejected') {
4244
- return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
4245
- }
4246
- const approvalGate = canvasState.resolveApproval(
4247
- id,
4248
- body.decision,
4249
- {
4250
- ...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
4251
- source: normalizeAxSource(body.source, 'api'),
4252
- },
4253
- );
4254
- if (!approvalGate) return responseJson({ ok: false, error: 'approval gate not found or already resolved.' }, 404);
4255
- broadcastWorkbenchEvent('ax-state-changed', {
4256
- approvalGate,
4257
- sessionId: primaryWorkbenchSessionId,
4258
- timestamp: new Date().toISOString(),
4259
- });
4260
- return responseJson({ ok: true, approvalGate });
4261
- }
4262
-
4263
- async function handleAxEvidenceAdd(req: Request): Promise<Response> {
4264
- const body = await readJson(req);
4265
- if (!isAxEvidenceKind(body.kind) || typeof body.title !== 'string' || !body.title.trim()) {
4266
- return responseJson({ ok: false, error: 'evidence requires kind and title.' }, 400);
4267
- }
4268
- const evidence = canvasState.addEvidence(
4269
- {
4270
- kind: body.kind,
4271
- title: body.title,
4272
- body: typeof body.body === 'string' ? body.body : null,
4273
- ref: typeof body.ref === 'string' ? body.ref : null,
4274
- nodeIds: normalizeAxNodeIds(body.nodeIds),
4275
- data: isRecord(body.data) ? body.data : null,
4276
- },
4277
- { source: normalizeAxSource(body.source, 'api') },
4278
- );
4279
- broadcastWorkbenchEvent('ax-event-created', {
4280
- evidence,
4281
- sessionId: primaryWorkbenchSessionId,
4282
- timestamp: new Date().toISOString(),
4283
- });
4284
- return responseJson({ ok: true, evidence });
4285
- }
2721
+ // handleAxApprovalRequest / handleAxApprovalResolve migrated to the operation
2722
+ // registry (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4286
2723
 
4287
- const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
4288
- const AX_REVIEW_SEVERITIES = new Set(['info', 'warning', 'error']);
4289
- const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
4290
- const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
2724
+ // handleAxEvidenceAdd migrated to the operation registry (plan-007 Slice B
2725
+ // wave 3): src/server/operations/ops/ax-timeline.ts.
4291
2726
 
4292
- function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
4293
- return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
4294
- }
4295
- function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
4296
- return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
4297
- }
4298
- function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
4299
- return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
4300
- }
4301
- function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
4302
- return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
4303
- }
4304
- function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
4305
- if (!isRecord(value)) return undefined;
4306
- return {
4307
- ...(typeof value.line === 'number' ? { line: value.line } : {}),
4308
- ...(typeof value.endLine === 'number' ? { endLine: value.endLine } : {}),
4309
- ...(typeof value.label === 'string' ? { label: value.label } : {}),
4310
- };
4311
- }
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.
4312
2729
 
4313
2730
  function handleAxReviewList(): Response {
4314
2731
  return responseJson({ ok: true, reviewAnnotations: canvasState.getReviewAnnotations() });
4315
2732
  }
4316
2733
 
4317
- async function handleAxReviewAdd(req: Request): Promise<Response> {
4318
- const body = await readJson(req);
4319
- if (typeof body.body !== 'string' || !body.body.trim()) {
4320
- return responseJson({ ok: false, error: 'review annotation requires a body.' }, 400);
4321
- }
4322
- const kind = normalizeAxReviewKind(body.kind);
4323
- const severity = normalizeAxReviewSeverity(body.severity);
4324
- const anchorType = normalizeAxReviewAnchor(body.anchorType);
4325
- const region = normalizeAxReviewRegion(body.region);
4326
- const reviewAnnotation = canvasState.addReviewAnnotation(
4327
- {
4328
- body: body.body,
4329
- ...(kind ? { kind } : {}),
4330
- ...(severity ? { severity } : {}),
4331
- ...(anchorType ? { anchorType } : {}),
4332
- ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
4333
- ...(typeof body.file === 'string' ? { file: body.file } : {}),
4334
- ...(region ? { region } : {}),
4335
- ...(typeof body.author === 'string' ? { author: body.author } : {}),
4336
- },
4337
- { source: normalizeAxSource(body.source, 'api') },
4338
- );
4339
- if (!reviewAnnotation) {
4340
- return responseJson({ ok: false, error: 'node-anchored review annotation requires a nodeId that exists on the canvas.' }, 400);
4341
- }
4342
- broadcastWorkbenchEvent('ax-state-changed', {
4343
- reviewAnnotation,
4344
- sessionId: primaryWorkbenchSessionId,
4345
- timestamp: new Date().toISOString(),
4346
- });
4347
- return responseJson({ ok: true, reviewAnnotation });
4348
- }
4349
-
4350
- async function handleAxReviewUpdate(req: Request, id: string): Promise<Response> {
4351
- const body = await readJson(req);
4352
- const status = normalizeAxReviewStatus(body.status);
4353
- const severity = normalizeAxReviewSeverity(body.severity);
4354
- const kind = normalizeAxReviewKind(body.kind);
4355
- const reviewAnnotation = canvasState.updateReviewAnnotation(
4356
- id,
4357
- {
4358
- ...(typeof body.body === 'string' ? { body: body.body } : {}),
4359
- ...(status ? { status } : {}),
4360
- ...(severity ? { severity } : {}),
4361
- ...(kind ? { kind } : {}),
4362
- },
4363
- { source: normalizeAxSource(body.source, 'api') },
4364
- );
4365
- if (!reviewAnnotation) return responseJson({ ok: false, error: 'review annotation not found.' }, 404);
4366
- broadcastWorkbenchEvent('ax-state-changed', {
4367
- reviewAnnotation,
4368
- sessionId: primaryWorkbenchSessionId,
4369
- timestamp: new Date().toISOString(),
4370
- });
4371
- return responseJson({ ok: true, reviewAnnotation });
4372
- }
2734
+ // handleAxReviewAdd / handleAxReviewUpdate migrated to the operation registry
2735
+ // (plan-007 Slice B wave 2): src/server/operations/ops/ax-work.ts.
4373
2736
 
4374
2737
  function handleAxHostCapabilityGet(): Response {
4375
2738
  return responseJson({ ok: true, host: canvasState.getHostCapability() });
4376
2739
  }
4377
2740
 
4378
- async function handleAxHostCapabilityReport(req: Request): Promise<Response> {
4379
- const body = await readJson(req);
4380
- const host = canvasState.setHostCapability(body, { source: normalizeAxSource(body.source, 'api') });
4381
- broadcastWorkbenchEvent('ax-state-changed', {
4382
- host,
4383
- sessionId: primaryWorkbenchSessionId,
4384
- timestamp: new Date().toISOString(),
4385
- });
4386
- return responseJson({ ok: true, host });
4387
- }
4388
-
4389
2741
  // ── Port resolution ───────────────────────────────────────────
4390
2742
 
4391
2743
  function buildPortCandidates(preferredPort: number): number[] {
@@ -5149,6 +3501,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5149
3501
  hostname: DEFAULT_HOST,
5150
3502
  port: portCandidate,
5151
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
+ },
5152
3513
  async fetch(req) {
5153
3514
  const url = new URL(req.url);
5154
3515
 
@@ -5209,30 +3570,20 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5209
3570
  return handleWorkbenchIntent(req);
5210
3571
  }
5211
3572
 
5212
- if (url.pathname === '/api/workbench/webview' && req.method === 'GET') {
5213
- return handleWorkbenchWebViewStatus();
5214
- }
5215
-
5216
- if (url.pathname === '/api/workbench/webview/start' && req.method === 'POST') {
5217
- return handleWorkbenchWebViewStart(req);
5218
- }
5219
-
5220
- if (url.pathname === '/api/workbench/webview/evaluate' && req.method === 'POST') {
5221
- return handleWorkbenchWebViewEvaluate(req);
5222
- }
5223
-
5224
- if (url.pathname === '/api/workbench/webview/resize' && req.method === 'POST') {
5225
- 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;
5226
3581
  }
5227
3582
 
5228
3583
  if (url.pathname === '/api/workbench/webview/screenshot' && req.method === 'POST') {
5229
3584
  return handleWorkbenchWebViewScreenshot(req);
5230
3585
  }
5231
3586
 
5232
- if (url.pathname === '/api/workbench/webview' && req.method === 'DELETE') {
5233
- return handleWorkbenchWebViewStop();
5234
- }
5235
-
5236
3587
  if (url.pathname === '/api/file/save' && req.method === 'POST') {
5237
3588
  return handleSave(req);
5238
3589
  }
@@ -5241,16 +3592,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5241
3592
  return handleRender(req);
5242
3593
  }
5243
3594
 
5244
- // Canvas state API
5245
- if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
5246
- const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
5247
- return responseJson(includeBlobs
5248
- ? serializeCanvasLayout(canvasState.getLayout())
5249
- : serializeCanvasLayoutWithBlobSummaries(withContextPinLayoutReadState(canvasState.getLayoutForPersistence())));
5250
- }
5251
-
5252
- if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
5253
- 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;
5254
3600
  }
5255
3601
 
5256
3602
  if (url.pathname === '/api/canvas/theme' && req.method === 'GET') {
@@ -5265,17 +3611,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5265
3611
  return handleCanvasUpdate(req);
5266
3612
  }
5267
3613
 
5268
- if (url.pathname === '/api/canvas/schema' && req.method === 'GET') {
5269
- return handleCanvasDescribeSchema();
5270
- }
5271
-
5272
- if (url.pathname === '/api/canvas/schema/validate' && req.method === 'POST') {
5273
- return handleCanvasValidateSpec(req);
5274
- }
5275
-
5276
- if (url.pathname === '/api/canvas/batch' && req.method === 'POST') {
5277
- return handleCanvasBatch(req);
5278
- }
3614
+ // POST /api/canvas/batch migrated to the operation registry
3615
+ // (plan-008 Wave 2): src/server/operations/ops/batch.ts.
5279
3616
 
5280
3617
  if (url.pathname === '/api/canvas/viewport' && req.method === 'POST') {
5281
3618
  return handleCanvasViewport(req);
@@ -5285,25 +3622,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5285
3622
  return handleCanvasAddAnnotation(req);
5286
3623
  }
5287
3624
 
5288
- if (url.pathname.startsWith('/api/canvas/annotation/') && req.method === 'DELETE') {
5289
- return handleCanvasRemoveAnnotation(url.pathname.slice('/api/canvas/annotation/'.length));
5290
- }
5291
-
5292
- if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
5293
- return handleCanvasAddNode(req);
5294
- }
5295
-
5296
- if (url.pathname === '/api/canvas/mcp-app/open' && req.method === 'POST') {
5297
- return handleCanvasOpenMcpApp(req);
5298
- }
5299
-
5300
- if (url.pathname === '/api/canvas/diagram' && req.method === 'POST') {
5301
- return handleCanvasAddDiagram(req);
5302
- }
5303
-
5304
- if (url.pathname === '/api/canvas/web-artifact' && req.method === 'POST') {
5305
- return handleCanvasBuildWebArtifact(req);
5306
- }
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.
5307
3628
 
5308
3629
  // Individual node GET/PATCH/DELETE
5309
3630
  if (url.pathname.startsWith('/api/canvas/node/') && url.pathname.endsWith('/refresh') && req.method === 'POST') {
@@ -5311,118 +3632,26 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5311
3632
  return handleCanvasRefreshWebpageNode(nodeId, req);
5312
3633
  }
5313
3634
 
5314
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
5315
- const nodeId = decodeURIComponent(url.pathname.slice('/api/canvas/node/'.length));
5316
- const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
5317
- const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
5318
- if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
5319
- const responseNode = withContextPinReadState(node);
5320
- return responseJson(includeBlobs
5321
- ? serializeCanvasNode(responseNode)
5322
- : serializeCanvasNodeWithBlobSummaries(responseNode));
5323
- }
5324
-
5325
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
5326
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
5327
- return handleCanvasUpdateNode(nodeId, req);
5328
- }
5329
-
5330
- if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'DELETE') {
5331
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
5332
- closeNodeAppSession(canvasState.getNode(nodeId));
5333
- const result = removeCanvasNode(nodeId);
5334
- if (!result.removed) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
5335
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5336
- if (result.needsCodeGraphRecompute) {
5337
- scheduleCodeGraphRecompute(() => {
5338
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5339
- });
5340
- }
5341
- return responseJson({ ok: true, removed: nodeId });
5342
- }
5343
-
5344
3635
  if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
5345
3636
  return await handleCanvasImage(url.pathname);
5346
3637
  }
5347
3638
 
5348
- if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
5349
- return handleCanvasAddEdge(req);
5350
- }
5351
-
5352
- if (url.pathname === '/api/canvas/edge' && req.method === 'DELETE') {
5353
- return handleCanvasRemoveEdge(req);
5354
- }
5355
-
5356
- // Snapshot API
5357
- if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
5358
- return responseJson(listCanvasSnapshots({
5359
- limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
5360
- query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
5361
- before: url.searchParams.get('before') ?? undefined,
5362
- after: url.searchParams.get('after') ?? undefined,
5363
- all: url.searchParams.get('all') === 'true',
5364
- }));
5365
- }
5366
-
5367
- if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
5368
- return handleSnapshotSave(req);
5369
- }
5370
-
5371
- if (url.pathname === '/api/canvas/snapshots/gc' && req.method === 'POST') {
5372
- return handleSnapshotGc(req);
5373
- }
5374
-
5375
- if (url.pathname === '/api/canvas/snapshots/diff' && req.method === 'GET') {
5376
- const name = url.searchParams.get('name') ?? url.searchParams.get('id') ?? '';
5377
- if (!name.trim()) return responseJson({ ok: false, error: 'Missing snapshot name or id.' }, 400);
5378
- const snapshot = canvasState.getSnapshotData(name);
5379
- if (!snapshot) return responseJson({ ok: false, error: `Snapshot "${name}" not found.` }, 404);
5380
- const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
5381
- return responseJson({ ok: true, text: formatDiff(diff), diff });
5382
- }
5383
-
5384
- if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
5385
- const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
5386
- const snapshot = canvasState.getSnapshotData(id);
5387
- if (!snapshot) return responseJson({ ok: false, error: `Snapshot "${id}" not found.` }, 404);
5388
- const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
5389
- return responseJson({ ok: true, text: formatDiff(diff), diff });
5390
- }
5391
-
5392
- if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'POST') {
5393
- const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length));
5394
- const result = await restoreCanvasSnapshot(id);
5395
- if (!result.ok) return responseText('Snapshot not found', 404);
5396
- broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5397
- return responseJson({ ok: true });
5398
- }
5399
-
5400
- if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'DELETE') {
5401
- const id = url.pathname.split('/').pop() ?? '';
5402
- const result = deleteCanvasSnapshot(id);
5403
- if (!result.ok) return responseText('Snapshot not found', 404);
5404
- return responseJson({ ok: true });
5405
- }
5406
-
5407
- // Context pins API
5408
- if (url.pathname === '/api/canvas/context-pins' && req.method === 'POST') {
5409
- return handleContextPinsUpdate(req);
5410
- }
5411
-
5412
3639
  if (url.pathname === '/api/canvas/pinned-context' && req.method === 'GET') {
5413
3640
  return handleGetPinnedContext();
5414
3641
  }
5415
3642
 
5416
- if (url.pathname === '/api/canvas/ax' && req.method === 'GET') {
5417
- return handleGetAxState();
5418
- }
3643
+ // GET /api/canvas/ax migrated to the operation registry (plan-007 Slice B.1).
5419
3644
 
5420
3645
  if (url.pathname === '/api/canvas/ax' && req.method === 'PATCH') {
5421
3646
  return handleAxStatePatch(req);
5422
3647
  }
5423
3648
 
5424
3649
  if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
5425
- return handleGetAxContext();
3650
+ return handleGetAxContext(url);
3651
+ }
3652
+
3653
+ if (url.pathname === '/api/canvas/ax/activity' && req.method === 'POST') {
3654
+ return handleAxActivityIngest(req);
5426
3655
  }
5427
3656
 
5428
3657
  if (url.pathname === '/api/canvas/ax/surface-snapshot' && req.method === 'GET') {
@@ -5433,187 +3662,85 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5433
3662
  return handleOpenExternalSurface(req);
5434
3663
  }
5435
3664
 
5436
- if (url.pathname === '/api/canvas/ax/focus' && req.method === 'POST') {
5437
- return handleAxFocusUpdate(req);
5438
- }
5439
-
5440
- if (url.pathname === '/api/canvas/ax/event' && req.method === 'POST') {
5441
- return handleAxEventAdd(req);
5442
- }
5443
-
5444
- if (url.pathname === '/api/canvas/ax/steer' && req.method === 'POST') {
5445
- return handleAxSteer(req);
5446
- }
3665
+ // POST /api/canvas/ax/focus migrated to the operation registry (plan-007 Slice B.1).
5447
3666
 
5448
- if (url.pathname === '/api/canvas/ax/timeline' && req.method === 'GET') {
5449
- return handleAxTimelineGet(url);
5450
- }
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.
5451
3670
 
5452
3671
  if (url.pathname === '/api/canvas/ax/work' && req.method === 'GET') {
5453
3672
  return handleAxWorkList();
5454
3673
  }
5455
3674
 
5456
- if (url.pathname === '/api/canvas/ax/work' && req.method === 'POST') {
5457
- return handleAxWorkAdd(req);
5458
- }
5459
-
5460
- if (url.pathname.startsWith('/api/canvas/ax/work/') && req.method === 'PATCH') {
5461
- const workItemId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/work/'.length));
5462
- return handleAxWorkUpdate(req, workItemId);
5463
- }
3675
+ // POST /api/canvas/ax/work + PATCH /api/canvas/ax/work/:id migrated to
3676
+ // the operation registry (plan-007 Slice B wave 2).
5464
3677
 
5465
3678
  if (url.pathname === '/api/canvas/ax/approval' && req.method === 'GET') {
5466
3679
  return handleAxApprovalList();
5467
3680
  }
5468
3681
 
5469
- if (url.pathname === '/api/canvas/ax/approval' && req.method === 'POST') {
5470
- return handleAxApprovalRequest(req);
5471
- }
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).
5472
3684
 
5473
- if (url.pathname.startsWith('/api/canvas/ax/approval/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5474
- const approvalId = decodeURIComponent(
5475
- url.pathname.slice('/api/canvas/ax/approval/'.length, -'/resolve'.length),
5476
- );
5477
- return handleAxApprovalResolve(req, approvalId);
5478
- }
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).
5479
3687
 
5480
- if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
5481
- return handleAxEvidenceAdd(req);
5482
- }
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.
5483
3690
 
5484
3691
  if (url.pathname === '/api/canvas/ax/review' && req.method === 'GET') {
5485
3692
  return handleAxReviewList();
5486
3693
  }
5487
3694
 
5488
- if (url.pathname === '/api/canvas/ax/review' && req.method === 'POST') {
5489
- return handleAxReviewAdd(req);
5490
- }
5491
-
5492
- if (url.pathname.startsWith('/api/canvas/ax/review/') && req.method === 'PATCH') {
5493
- const reviewId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/review/'.length));
5494
- return handleAxReviewUpdate(req, reviewId);
5495
- }
3695
+ // POST /api/canvas/ax/review + PATCH /api/canvas/ax/review/:id migrated
3696
+ // to the operation registry (plan-007 Slice B wave 2).
5496
3697
 
5497
3698
  if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'GET') {
5498
3699
  return handleAxHostCapabilityGet();
5499
3700
  }
5500
3701
 
5501
- if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'PUT') {
5502
- return handleAxHostCapabilityReport(req);
5503
- }
3702
+ // PUT /api/canvas/ax/host-capability migrated to the operation registry (plan-007 Slice B.1).
5504
3703
 
5505
3704
  if (url.pathname === '/api/canvas/ax/interaction' && req.method === 'POST') {
5506
3705
  return handleAxInteraction(req);
5507
3706
  }
5508
3707
 
5509
- if (url.pathname === '/api/canvas/ax/delivery/pending' && req.method === 'GET') {
5510
- return handleAxDeliveryPending(url);
5511
- }
5512
-
5513
- if (url.pathname.startsWith('/api/canvas/ax/delivery/') && url.pathname.endsWith('/mark') && req.method === 'POST') {
5514
- const deliveryId = decodeURIComponent(
5515
- url.pathname.slice('/api/canvas/ax/delivery/'.length, -'/mark'.length),
5516
- );
5517
- return handleAxDeliveryMark(deliveryId);
5518
- }
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.
5519
3711
 
5520
3712
  if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'GET') {
5521
3713
  return handleAxElicitationList();
5522
3714
  }
5523
3715
 
5524
- if (url.pathname === '/api/canvas/ax/elicitation' && req.method === 'POST') {
5525
- return handleAxElicitationRequest(req);
5526
- }
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).
5527
3718
 
5528
- if (url.pathname.startsWith('/api/canvas/ax/elicitation/') && url.pathname.endsWith('/respond') && req.method === 'POST') {
5529
- const elicitationId = decodeURIComponent(
5530
- url.pathname.slice('/api/canvas/ax/elicitation/'.length, -'/respond'.length),
5531
- );
5532
- return handleAxElicitationRespond(req, elicitationId);
5533
- }
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).
5534
3721
 
5535
3722
  if (url.pathname === '/api/canvas/ax/mode' && req.method === 'GET') {
5536
3723
  return handleAxModeList();
5537
3724
  }
5538
3725
 
5539
- if (url.pathname === '/api/canvas/ax/mode' && req.method === 'POST') {
5540
- return handleAxModeRequest(req);
5541
- }
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).
5542
3728
 
5543
- if (url.pathname.startsWith('/api/canvas/ax/mode/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
5544
- const modeId = decodeURIComponent(
5545
- url.pathname.slice('/api/canvas/ax/mode/'.length, -'/resolve'.length),
5546
- );
5547
- return handleAxModeResolve(req, modeId);
5548
- }
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).
5549
3731
 
5550
3732
  if (url.pathname === '/api/canvas/ax/command' && req.method === 'GET') {
5551
3733
  return handleAxCommandList();
5552
3734
  }
5553
3735
 
5554
- if (url.pathname === '/api/canvas/ax/command' && req.method === 'POST') {
5555
- return handleAxCommandInvoke(req);
5556
- }
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.
5557
3738
 
5558
3739
  if (url.pathname === '/api/canvas/ax/policy' && req.method === 'GET') {
5559
3740
  return handleAxPolicyGet();
5560
3741
  }
5561
3742
 
5562
- if (url.pathname === '/api/canvas/ax/policy' && req.method === 'POST') {
5563
- return handleAxPolicySet(req);
5564
- }
5565
-
5566
- // Spatial context API
5567
- if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
5568
- const layout = canvasState.getLayout();
5569
- const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds, layout.annotations);
5570
- return responseJson(spatial);
5571
- }
5572
-
5573
- // Search API
5574
- if (url.pathname === '/api/canvas/search' && req.method === 'GET') {
5575
- const q = url.searchParams.get('q') ?? '';
5576
- if (!q.trim()) {
5577
- return responseJson({ results: [], query: q });
5578
- }
5579
- const results = searchNodes(canvasState.getLayout().nodes, q);
5580
- return responseJson({ results, query: q });
5581
- }
5582
-
5583
- // Group API
5584
- if (url.pathname === '/api/canvas/group' && req.method === 'POST') {
5585
- return handleCanvasCreateGroup(req);
5586
- }
5587
-
5588
- if (url.pathname === '/api/canvas/group/add' && req.method === 'POST') {
5589
- return handleCanvasGroupNodes(req);
5590
- }
5591
-
5592
- if (url.pathname === '/api/canvas/group/ungroup' && req.method === 'POST') {
5593
- return handleCanvasUngroupNodes(req);
5594
- }
5595
-
5596
- // Arrange / Focus / Clear API (for agent CLI)
5597
- if (url.pathname === '/api/canvas/arrange' && req.method === 'POST') {
5598
- return handleCanvasArrange(req);
5599
- }
5600
-
5601
- if (url.pathname === '/api/canvas/focus' && req.method === 'POST') {
5602
- return handleCanvasFocus(req);
5603
- }
5604
-
5605
- if (url.pathname === '/api/canvas/fit' && req.method === 'POST') {
5606
- return handleCanvasFit(req);
5607
- }
5608
-
5609
- if (url.pathname === '/api/canvas/clear' && req.method === 'POST') {
5610
- for (const node of canvasState.getLayout().nodes) {
5611
- closeNodeAppSession(node);
5612
- }
5613
- clearCanvas();
5614
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5615
- return responseJson({ ok: true });
5616
- }
3743
+ // POST /api/canvas/ax/policy migrated to the operation registry (plan-007 Slice B.1).
5617
3744
 
5618
3745
  // Code graph API
5619
3746
  if (url.pathname === '/api/canvas/code-graph' && req.method === 'GET') {
@@ -5621,54 +3748,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5621
3748
  return responseJson(summary);
5622
3749
  }
5623
3750
 
5624
- if (url.pathname === '/api/canvas/json-render' && req.method === 'POST') {
5625
- return handleCanvasAddJsonRender(req);
5626
- }
5627
-
5628
- if (url.pathname === '/api/canvas/json-render/stream' && req.method === 'POST') {
5629
- return handleJsonRenderStream(req);
5630
- }
5631
-
5632
- if (url.pathname === '/api/canvas/graph' && req.method === 'POST') {
5633
- return handleCanvasAddGraph(req);
5634
- }
5635
-
5636
3751
  if (url.pathname === '/api/canvas/prompt' && req.method === 'POST') {
5637
3752
  return handleCanvasPrompt(req);
5638
3753
  }
5639
3754
 
5640
- // Undo/Redo/History API
5641
- if (url.pathname === '/api/canvas/undo' && req.method === 'POST') {
5642
- const entry = mutationHistory.undo();
5643
- if (!entry) return responseJson({ ok: false, description: 'Nothing to undo' });
5644
- await syncCanvasRuntimeBackends();
5645
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
5646
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5647
- return responseJson({ ok: true, description: `Undid: ${entry.description}` });
5648
- }
5649
-
5650
- if (url.pathname === '/api/canvas/redo' && req.method === 'POST') {
5651
- const entry = mutationHistory.redo();
5652
- if (!entry) return responseJson({ ok: false, description: 'Nothing to redo' });
5653
- await syncCanvasRuntimeBackends();
5654
- emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
5655
- emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
5656
- return responseJson({ ok: true, description: `Redid: ${entry.description}` });
5657
- }
5658
-
5659
- if (url.pathname === '/api/canvas/history' && req.method === 'GET') {
5660
- return responseJson({
5661
- text: mutationHistory.toHumanReadable(),
5662
- entries: mutationHistory.getSummaries(),
5663
- canUndo: mutationHistory.canUndo(),
5664
- canRedo: mutationHistory.canRedo(),
5665
- });
5666
- }
5667
-
5668
- if (url.pathname === '/api/canvas/validate' && req.method === 'GET') {
5669
- return handleCanvasValidate();
5670
- }
5671
-
5672
3755
  if (url.pathname === '/api/ext-app/call-tool' && req.method === 'POST') {
5673
3756
  return handleExtAppCallTool(req);
5674
3757
  }