pmx-canvas 0.1.36 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +409 -0
- package/Readme.md +2 -2
- package/dist/json-render/index.js +89 -334
- package/dist/types/mcp/canvas-access.d.ts +5 -171
- package/dist/types/server/ax-state-manager.d.ts +256 -0
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +3 -23
- package/dist/types/server/index.d.ts +6 -24
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-state-contract.md +72 -0
- package/docs/mcp.md +60 -11
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +192 -186
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +4 -5
- package/src/cli/agent.ts +52 -31
- package/src/mcp/canvas-access.ts +30 -830
- package/src/mcp/server.ts +162 -2014
- package/src/server/ax-state-manager.ts +808 -0
- package/src/server/ax-state.ts +2 -2
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +95 -465
- package/src/server/index.ts +54 -190
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +158 -2255
- package/src/server/web-artifacts.ts +6 -2
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 4 operations (plan-005, migration item 6): jsonrender.add /
|
|
3
|
+
* jsonrender.stream / graph.add / schema.describe / spec.validate — plus the
|
|
4
|
+
* shared `streamJsonRenderCore` the SDK wraps directly.
|
|
5
|
+
*
|
|
6
|
+
* Frame-height alias triangle (graph nodes), absorbed into ONE schema here:
|
|
7
|
+
* - `height` = CHART CONTENT height (goes into the json-render spec /
|
|
8
|
+
* graphConfig, NOT the node frame). Same name on every
|
|
9
|
+
* surface.
|
|
10
|
+
* - `nodeHeight` = node FRAME height as named by the HTTP body and the MCP
|
|
11
|
+
* tool argument.
|
|
12
|
+
* - `heightPx` = node FRAME height as named by the SDK's GraphNodeInput
|
|
13
|
+
* (createCanvasGraphNode's own input field); the legacy
|
|
14
|
+
* RemoteCanvasAccess re-mapped heightPx → nodeHeight over
|
|
15
|
+
* HTTP.
|
|
16
|
+
* - `size.height` = node FRAME height in the `{ size: { width, height } }`
|
|
17
|
+
* object-alias form shared with other node-create routes.
|
|
18
|
+
* Resolution order (legacy-exact): nodeHeight ?? heightPx ?? size.height.
|
|
19
|
+
*
|
|
20
|
+
* Unification notes (documented deltas from legacy):
|
|
21
|
+
* - spec.validate's graph branch now honors the full graph payload surface
|
|
22
|
+
* (colorBy, highlight, labelKey, targetKey, rangesKey, beforeKey, afterKey,
|
|
23
|
+
* beforeLabel, afterLabel, sort, fill, showEndDot, showMinMax, showValue,
|
|
24
|
+
* colorByDirection). The legacy HTTP handler silently dropped these fields
|
|
25
|
+
* while the legacy MCP tool (and the graph CREATE route) honored them —
|
|
26
|
+
* exactly the one-of-N-paths drift this registry erases.
|
|
27
|
+
* - jsonrender.stream's `nodeHeight` MCP alias is mapped onto the HTTP body's
|
|
28
|
+
* `height` in buildInput, so local and remote MCP invocations are identical.
|
|
29
|
+
*
|
|
30
|
+
* This module must never import server.ts or index.ts.
|
|
31
|
+
*/
|
|
32
|
+
import { z } from 'zod';
|
|
33
|
+
import { canvasState, type CanvasNodeState } from '../../canvas-state.js';
|
|
34
|
+
import {
|
|
35
|
+
appendCanvasJsonRenderStream,
|
|
36
|
+
createCanvasGraphNode,
|
|
37
|
+
createCanvasJsonRenderNode,
|
|
38
|
+
createCanvasStreamingJsonRenderNode,
|
|
39
|
+
} from '../../canvas-operations.js';
|
|
40
|
+
import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
|
|
41
|
+
import { isHtmlPrimitiveKind } from '../../html-primitives.js';
|
|
42
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
43
|
+
import {
|
|
44
|
+
buildNodeResponse,
|
|
45
|
+
compactNodePayload,
|
|
46
|
+
createdNodePayloadFromNode,
|
|
47
|
+
getRecord,
|
|
48
|
+
isRecord,
|
|
49
|
+
pickFiniteNumber,
|
|
50
|
+
pickPositiveNumber,
|
|
51
|
+
resolveCreateGeometry,
|
|
52
|
+
} from './nodes.js';
|
|
53
|
+
|
|
54
|
+
/** Legacy server.ts parseGraphPayloadData: a graph dataset must be an array of records. */
|
|
55
|
+
export function parseGraphPayloadData(value: unknown): Array<Record<string, unknown>> | null {
|
|
56
|
+
if (!Array.isArray(value)) return null;
|
|
57
|
+
if (value.some((item) => !isRecord(item))) return null;
|
|
58
|
+
return value as Array<Record<string, unknown>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** MCP json-render spec schema (moved from mcp/server.ts): full document or bare component. */
|
|
62
|
+
const jsonRenderSpecSchema = z.union([
|
|
63
|
+
z.object({
|
|
64
|
+
root: z.string(),
|
|
65
|
+
elements: z.record(z.string(), z.unknown()),
|
|
66
|
+
state: z.record(z.string(), z.unknown()).optional(),
|
|
67
|
+
}).passthrough(),
|
|
68
|
+
z.object({
|
|
69
|
+
type: z.string(),
|
|
70
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
71
|
+
children: z.array(z.string()).optional(),
|
|
72
|
+
}).passthrough(),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const htmlPrimitiveKindSchema = z.string().refine(isHtmlPrimitiveKind, 'Unknown HTML primitive kind');
|
|
76
|
+
|
|
77
|
+
/** MCP node-create payload for json-render/graph adds (legacy `createdNodePayload` + url + spec). */
|
|
78
|
+
function structuredNodeToolResult(result: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
79
|
+
const body = isRecord(result) ? result : {};
|
|
80
|
+
const node = body.node as CanvasNodeState | undefined;
|
|
81
|
+
const payload = {
|
|
82
|
+
...(node ? createdNodePayloadFromNode(node) : { ok: true }),
|
|
83
|
+
url: body.url,
|
|
84
|
+
spec: body.spec,
|
|
85
|
+
};
|
|
86
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── jsonrender.add ────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
const jsonRenderAddShape = {
|
|
92
|
+
title: z.string().optional().catch(undefined).describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
|
|
93
|
+
spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
|
|
94
|
+
x: z.number().optional().catch(undefined).describe('Optional X position'),
|
|
95
|
+
y: z.number().optional().catch(undefined).describe('Optional Y position'),
|
|
96
|
+
width: z.number().optional().catch(undefined).describe('Optional node width'),
|
|
97
|
+
height: z.number().optional().catch(undefined).describe('Optional node height'),
|
|
98
|
+
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit width/height fixed and scroll overflowing content instead of browser auto-fitting'),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const jsonRenderAddSchema = z.looseObject(jsonRenderAddShape);
|
|
102
|
+
|
|
103
|
+
const jsonRenderAddOperation = defineOperation<
|
|
104
|
+
z.infer<typeof jsonRenderAddSchema>,
|
|
105
|
+
ReturnType<typeof createCanvasJsonRenderNode>
|
|
106
|
+
>({
|
|
107
|
+
name: 'jsonrender.add',
|
|
108
|
+
mutates: true,
|
|
109
|
+
input: jsonRenderAddSchema,
|
|
110
|
+
inputShape: jsonRenderAddShape,
|
|
111
|
+
http: {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
path: '/api/canvas/json-render',
|
|
114
|
+
},
|
|
115
|
+
mcp: {
|
|
116
|
+
toolName: 'canvas_add_json_render_node',
|
|
117
|
+
description: 'Create a native json-render canvas node from a complete spec. Use this for structured dashboards, forms, tables, and other interactive UI panels that should render directly inside PMX Canvas.',
|
|
118
|
+
formatResult: (result) => structuredNodeToolResult(result),
|
|
119
|
+
},
|
|
120
|
+
handler: (input) => {
|
|
121
|
+
const body: Record<string, unknown> = input;
|
|
122
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
123
|
+
// Legacy fallback: a body without an object `spec` is treated as the spec
|
|
124
|
+
// itself (bare-component compatibility path).
|
|
125
|
+
const rawSpec =
|
|
126
|
+
body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
|
|
127
|
+
const geometry = resolveCreateGeometry(body);
|
|
128
|
+
try {
|
|
129
|
+
return createCanvasJsonRenderNode({
|
|
130
|
+
...(title ? { title } : {}),
|
|
131
|
+
spec: rawSpec,
|
|
132
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
133
|
+
...geometry,
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
serialize: (result) => ({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec }),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── jsonrender.stream ─────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export interface StreamJsonRenderInput {
|
|
145
|
+
nodeId?: string;
|
|
146
|
+
title?: string;
|
|
147
|
+
patches?: unknown[];
|
|
148
|
+
done?: boolean;
|
|
149
|
+
x?: number;
|
|
150
|
+
y?: number;
|
|
151
|
+
width?: number;
|
|
152
|
+
height?: number;
|
|
153
|
+
strictSize?: boolean;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface StreamJsonRenderResult {
|
|
157
|
+
id: string;
|
|
158
|
+
url: string;
|
|
159
|
+
ok: true;
|
|
160
|
+
applied: number;
|
|
161
|
+
skipped: number;
|
|
162
|
+
specVersion: number;
|
|
163
|
+
elementCount: number;
|
|
164
|
+
streamStatus: 'open' | 'closed';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create-or-append core for streaming json-render nodes (the SDK's
|
|
169
|
+
* streamJsonRenderNode wraps this directly). Throws OperationError(400) when
|
|
170
|
+
* the append target is missing or not a json-render node.
|
|
171
|
+
*/
|
|
172
|
+
export function streamJsonRenderCore(input: StreamJsonRenderInput): StreamJsonRenderResult {
|
|
173
|
+
const patches = Array.isArray(input.patches) ? input.patches : [];
|
|
174
|
+
const done = input.done === true;
|
|
175
|
+
let nodeId = typeof input.nodeId === 'string' && input.nodeId ? input.nodeId : undefined;
|
|
176
|
+
let url = '';
|
|
177
|
+
if (!nodeId) {
|
|
178
|
+
const created = createCanvasStreamingJsonRenderNode({
|
|
179
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
180
|
+
...(input.strictSize === true ? { strictSize: true } : {}),
|
|
181
|
+
...(input.x !== undefined ? { x: input.x } : {}),
|
|
182
|
+
...(input.y !== undefined ? { y: input.y } : {}),
|
|
183
|
+
...(input.width !== undefined ? { width: input.width } : {}),
|
|
184
|
+
...(input.height !== undefined ? { height: input.height } : {}),
|
|
185
|
+
});
|
|
186
|
+
nodeId = created.id;
|
|
187
|
+
url = created.url;
|
|
188
|
+
}
|
|
189
|
+
const result = appendCanvasJsonRenderStream(nodeId, patches, done);
|
|
190
|
+
if (!result.ok) throw new OperationError(result.error);
|
|
191
|
+
const node = canvasState.getNode(nodeId);
|
|
192
|
+
return { id: nodeId, url: url || String(node?.data.url ?? ''), ...result };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const jsonRenderStreamShape = {
|
|
196
|
+
nodeId: z.string().optional().catch(undefined).describe('Existing streaming node id to append to; omit to create a new streaming node'),
|
|
197
|
+
title: z.string().optional().catch(undefined).describe('Title when creating a new streaming node'),
|
|
198
|
+
patches: z.unknown().optional().describe('SpecStream patches to apply this call: JSON-Patch objects ({op,path,value}) or raw JSONL patch lines'),
|
|
199
|
+
done: z.boolean().optional().catch(undefined).describe('Set true on the final call to mark the stream complete'),
|
|
200
|
+
x: z.number().optional().catch(undefined).describe('Optional X position (new node)'),
|
|
201
|
+
y: z.number().optional().catch(undefined).describe('Optional Y position (new node)'),
|
|
202
|
+
width: z.number().optional().catch(undefined).describe('Optional node width (new node)'),
|
|
203
|
+
nodeHeight: z.number().optional().catch(undefined).describe('Optional node height (new node)'),
|
|
204
|
+
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit node size fixed and scroll overflowing content (new node)'),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const jsonRenderStreamSchema = z.looseObject(jsonRenderStreamShape);
|
|
208
|
+
|
|
209
|
+
const jsonRenderStreamOperation = defineOperation<
|
|
210
|
+
z.infer<typeof jsonRenderStreamSchema>,
|
|
211
|
+
StreamJsonRenderResult
|
|
212
|
+
>({
|
|
213
|
+
name: 'jsonrender.stream',
|
|
214
|
+
mutates: true,
|
|
215
|
+
input: jsonRenderStreamSchema,
|
|
216
|
+
inputShape: jsonRenderStreamShape,
|
|
217
|
+
http: {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
path: '/api/canvas/json-render/stream',
|
|
220
|
+
},
|
|
221
|
+
mcp: {
|
|
222
|
+
toolName: 'canvas_stream_json_render_node',
|
|
223
|
+
description: 'Progressively build a json-render node by streaming SpecStream patches, so a panel fills in live as you generate it. Omit nodeId on the first call to create a new streaming node (returns its id); pass that same nodeId on later calls to append more patches; set done=true on the final call. Each call updates the live node. Patches are JSON-Patch operations, e.g. {"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Live"},"children":[]}}, {"op":"replace","path":"/root","value":"card"}, {"op":"add","path":"/elements/card/children/-","value":"row1"}. Build the spec incrementally: set /root, add container elements, then append children. The server accumulates the spec (it is the source of truth); partial specs render what they can.',
|
|
224
|
+
extraShape: {
|
|
225
|
+
// Strict patch typing for the MCP surface only; the operation schema
|
|
226
|
+
// stays loose so the HTTP route keeps tolerating malformed patch lists
|
|
227
|
+
// (they fall through to the skipped counter, legacy behavior).
|
|
228
|
+
patches: z
|
|
229
|
+
.array(z.union([z.string(), z.record(z.string(), z.unknown())]))
|
|
230
|
+
.optional()
|
|
231
|
+
.describe('SpecStream patches to apply this call: JSON-Patch objects ({op,path,value}) or raw JSONL patch lines'),
|
|
232
|
+
},
|
|
233
|
+
buildInput: (input) => {
|
|
234
|
+
// MCP names the frame height `nodeHeight`; the HTTP body uses `height`.
|
|
235
|
+
const { nodeHeight, ...rest } = input;
|
|
236
|
+
return { ...rest, ...(typeof nodeHeight === 'number' ? { height: nodeHeight } : {}) };
|
|
237
|
+
},
|
|
238
|
+
formatResult: async (result, _input, host) => {
|
|
239
|
+
const body = isRecord(result) ? result : {};
|
|
240
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
241
|
+
// Legacy createdNodePayload(c, id): a follow-up node read; a missing
|
|
242
|
+
// node degrades to the bare { ok, id, nodeId } payload.
|
|
243
|
+
let created: Record<string, unknown> = { ok: true, id, nodeId: id };
|
|
244
|
+
try {
|
|
245
|
+
const node = await host.invoker().invoke('node.get', { id, includeBlobs: true }) as CanvasNodeState;
|
|
246
|
+
created = { ok: true, node: compactNodePayload(node), id, nodeId: id };
|
|
247
|
+
} catch {
|
|
248
|
+
// keep the bare payload (legacy c.getNode → undefined path)
|
|
249
|
+
}
|
|
250
|
+
const payload = {
|
|
251
|
+
...created,
|
|
252
|
+
url: body.url,
|
|
253
|
+
applied: body.applied,
|
|
254
|
+
skipped: body.skipped,
|
|
255
|
+
specVersion: body.specVersion,
|
|
256
|
+
elementCount: body.elementCount,
|
|
257
|
+
streamStatus: body.streamStatus,
|
|
258
|
+
};
|
|
259
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }] };
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
handler: (input) => {
|
|
263
|
+
const body: Record<string, unknown> = input;
|
|
264
|
+
const geometry = resolveCreateGeometry(body);
|
|
265
|
+
return streamJsonRenderCore({
|
|
266
|
+
...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
|
|
267
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
268
|
+
...(Array.isArray(body.patches) ? { patches: body.patches } : {}),
|
|
269
|
+
...(body.done === true ? { done: true } : {}),
|
|
270
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
271
|
+
...geometry,
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
// Wire shape is `{ id, url, ok, applied, ... }` — handler output verbatim.
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── graph.add ─────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const graphAddShape = {
|
|
280
|
+
title: z.string().optional().catch(undefined).describe('Optional node title'),
|
|
281
|
+
graphType: z.string().optional().catch(undefined).describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo"), sparkline, dot-plot (or "dot"), bullet, slopegraph (or "slope")'),
|
|
282
|
+
data: z.unknown().optional().describe('Array of chart data objects'),
|
|
283
|
+
xKey: z.string().optional().catch(undefined).describe('X-axis key (line/bar/area/scatter/stacked/composed)'),
|
|
284
|
+
yKey: z.string().optional().catch(undefined).describe('Y-axis key (line/bar/area/scatter); falls back to barKey for composed'),
|
|
285
|
+
zKey: z.string().optional().catch(undefined).describe('Optional bubble-size key for scatter charts'),
|
|
286
|
+
nameKey: z.string().optional().catch(undefined).describe('Name key for pie graphs'),
|
|
287
|
+
valueKey: z.string().optional().catch(undefined).describe('Value key for pie slices, sparkline, dot-plot, and the bullet measure'),
|
|
288
|
+
axisKey: z.string().optional().catch(undefined).describe('Category key for radar charts'),
|
|
289
|
+
metrics: z.array(z.string()).optional().catch(undefined).describe('Series keys to plot as radar polygons (defaults to non-axis numeric columns)'),
|
|
290
|
+
series: z.array(z.string()).optional().catch(undefined).describe('Series keys for stacked-bar segments (defaults to non-x numeric columns)'),
|
|
291
|
+
barKey: z.string().optional().catch(undefined).describe('Bar series key for composed charts'),
|
|
292
|
+
lineKey: z.string().optional().catch(undefined).describe('Line series key for composed charts'),
|
|
293
|
+
aggregate: z.enum(['sum', 'count', 'avg']).optional().catch(undefined).describe('Optional aggregation for repeated x-axis values (line/bar/area/stacked)'),
|
|
294
|
+
color: z.string().optional().catch(undefined).describe('Optional series color (line/bar/area/scatter)'),
|
|
295
|
+
colorBy: z
|
|
296
|
+
.enum(['series', 'category', 'value', 'none'])
|
|
297
|
+
.optional()
|
|
298
|
+
.catch(undefined)
|
|
299
|
+
.describe("Bar charts only: how bars are colored. 'series' (default) = single accent with one highlighted bar; 'category' = rotate palette per bar; 'value' = shade by magnitude; 'none' = flat. Prefer 'series' — color should encode data, not decorate."),
|
|
300
|
+
highlight: z
|
|
301
|
+
.union([z.number(), z.enum(['max', 'min'])])
|
|
302
|
+
.nullable()
|
|
303
|
+
.optional()
|
|
304
|
+
.catch(undefined)
|
|
305
|
+
.describe("Bar charts only, for colorBy='series': which bar gets the accent — 'max' (default), 'min', a 0-based index, or null for no emphasis."),
|
|
306
|
+
barColor: z.string().optional().catch(undefined).describe('Optional bar color for composed charts'),
|
|
307
|
+
lineColor: z.string().optional().catch(undefined).describe('Optional line color for composed charts'),
|
|
308
|
+
labelKey: z.string().optional().catch(undefined).describe('Category label key for dot-plot / bullet / slopegraph rows'),
|
|
309
|
+
targetKey: z.string().optional().catch(undefined).describe('Per-row target value key for bullet charts'),
|
|
310
|
+
rangesKey: z.string().optional().catch(undefined).describe('Per-row qualitative band thresholds key (number[]) for bullet charts'),
|
|
311
|
+
beforeKey: z.string().optional().catch(undefined).describe('Left-column value key for slopegraph'),
|
|
312
|
+
afterKey: z.string().optional().catch(undefined).describe('Right-column value key for slopegraph'),
|
|
313
|
+
beforeLabel: z.string().optional().catch(undefined).describe('Header label for the slopegraph left column'),
|
|
314
|
+
afterLabel: z.string().optional().catch(undefined).describe('Header label for the slopegraph right column'),
|
|
315
|
+
sort: z.enum(['asc', 'desc', 'none']).optional().catch(undefined).describe('Row sort order for dot-plot (defaults to desc)'),
|
|
316
|
+
fill: z.boolean().optional().catch(undefined).describe('Sparkline: draw a light area fill under the line'),
|
|
317
|
+
showEndDot: z.boolean().optional().catch(undefined).describe('Sparkline: draw a dot at the last point (default true)'),
|
|
318
|
+
showMinMax: z.boolean().optional().catch(undefined).describe('Sparkline: mark the min and max points'),
|
|
319
|
+
showValue: z.boolean().optional().catch(undefined).describe('Sparkline: print the last value inline'),
|
|
320
|
+
colorByDirection: z.boolean().optional().catch(undefined).describe('Slopegraph: accent rising lines and mute falling ones (default off — lines use one neutral ink)'),
|
|
321
|
+
// CHART CONTENT height — see the alias-triangle note at the top of this file.
|
|
322
|
+
height: z.number().optional().catch(undefined).describe('Optional chart content height'),
|
|
323
|
+
showLegend: z.boolean().optional().catch(undefined).describe('Show chart legend when supported; pass false for compact node layouts'),
|
|
324
|
+
showLabels: z.boolean().optional().catch(undefined).describe('Show direct labels when supported, such as pie slice labels (defaults to true)'),
|
|
325
|
+
x: z.number().optional().catch(undefined).describe('Optional X position'),
|
|
326
|
+
y: z.number().optional().catch(undefined).describe('Optional Y position'),
|
|
327
|
+
width: z.number().optional().catch(undefined).describe('Optional node width'),
|
|
328
|
+
// Node FRAME height (HTTP/MCP name) — see the alias-triangle note.
|
|
329
|
+
nodeHeight: z.number().optional().catch(undefined).describe('Optional node height'),
|
|
330
|
+
// Node FRAME height (SDK GraphNodeInput field name) — see the alias-triangle note.
|
|
331
|
+
heightPx: z.number().optional().catch(undefined).describe('SDK alias for nodeHeight (node frame height)'),
|
|
332
|
+
strictSize: z.boolean().optional().catch(undefined).describe('Keep explicit node size fixed and scroll overflowing content instead of browser auto-fitting'),
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const graphAddSchema = z.looseObject(graphAddShape);
|
|
336
|
+
|
|
337
|
+
const graphAddOperation = defineOperation<
|
|
338
|
+
z.infer<typeof graphAddSchema>,
|
|
339
|
+
ReturnType<typeof createCanvasGraphNode>
|
|
340
|
+
>({
|
|
341
|
+
name: 'graph.add',
|
|
342
|
+
mutates: true,
|
|
343
|
+
input: graphAddSchema,
|
|
344
|
+
inputShape: graphAddShape,
|
|
345
|
+
http: {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
path: '/api/canvas/graph',
|
|
348
|
+
},
|
|
349
|
+
mcp: {
|
|
350
|
+
toolName: 'canvas_add_graph_node',
|
|
351
|
+
description: 'Create a native graph node backed by the json-render chart catalog. Supports line, bar, pie, area, scatter, radar, stacked-bar, composed (bar+line), sparkline, dot-plot (Cleveland), bullet (Few KPI vs target), and slopegraph (paired before/after) graphs rendered directly inside PMX Canvas.',
|
|
352
|
+
extraShape: {
|
|
353
|
+
graphType: z.string().describe('Graph type: line, bar, pie, area, scatter, radar, stacked-bar (or "stack"), composed (or "combo"), sparkline, dot-plot (or "dot"), bullet, slopegraph (or "slope")'),
|
|
354
|
+
data: z.array(z.record(z.string(), z.unknown())).describe('Array of chart data objects'),
|
|
355
|
+
},
|
|
356
|
+
formatResult: (result) => structuredNodeToolResult(result),
|
|
357
|
+
},
|
|
358
|
+
handler: (input) => {
|
|
359
|
+
const body: Record<string, unknown> = input;
|
|
360
|
+
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
|
|
361
|
+
// `type` is the legacy HTTP-only graphType alias (not advertised over MCP).
|
|
362
|
+
const graphType = typeof body.graphType === 'string' ? body.graphType : typeof body.type === 'string' ? body.type : 'line';
|
|
363
|
+
const data = parseGraphPayloadData(body.data);
|
|
364
|
+
if (!data) {
|
|
365
|
+
throw new OperationError('Missing required field: data.');
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const aggregate =
|
|
369
|
+
body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
|
|
370
|
+
? body.aggregate
|
|
371
|
+
: undefined;
|
|
372
|
+
const metrics = Array.isArray(body.metrics)
|
|
373
|
+
? body.metrics.filter((m: unknown): m is string => typeof m === 'string')
|
|
374
|
+
: null;
|
|
375
|
+
const series = Array.isArray(body.series)
|
|
376
|
+
? body.series.filter((s: unknown): s is string => typeof s === 'string')
|
|
377
|
+
: null;
|
|
378
|
+
const position = getRecord(body.position);
|
|
379
|
+
const size = getRecord(body.size);
|
|
380
|
+
const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
|
|
381
|
+
const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
|
|
382
|
+
const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
|
|
383
|
+
// Node FRAME height. `body.height` is the CHART plot height (passed
|
|
384
|
+
// through as `height` below) — see the alias-triangle note at the top.
|
|
385
|
+
const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
|
|
386
|
+
?? pickPositiveNumber(body, 'heightPx')
|
|
387
|
+
?? (size ? pickPositiveNumber(size, 'height') : undefined);
|
|
388
|
+
const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
|
|
389
|
+
const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
|
|
390
|
+
const colorBy =
|
|
391
|
+
body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
|
|
392
|
+
? body.colorBy
|
|
393
|
+
: undefined;
|
|
394
|
+
const highlight =
|
|
395
|
+
typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
|
|
396
|
+
? body.highlight
|
|
397
|
+
: undefined;
|
|
398
|
+
const sort =
|
|
399
|
+
body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
|
|
400
|
+
return createCanvasGraphNode({
|
|
401
|
+
title,
|
|
402
|
+
graphType,
|
|
403
|
+
data,
|
|
404
|
+
...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
|
|
405
|
+
...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
|
|
406
|
+
...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
|
|
407
|
+
...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
|
|
408
|
+
...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
|
|
409
|
+
...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
|
|
410
|
+
...(metrics ? { metrics } : {}),
|
|
411
|
+
...(series ? { series } : {}),
|
|
412
|
+
...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
|
|
413
|
+
...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
|
|
414
|
+
...(aggregate ? { aggregate } : {}),
|
|
415
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
416
|
+
...(colorBy ? { colorBy } : {}),
|
|
417
|
+
...(highlight !== undefined ? { highlight } : {}),
|
|
418
|
+
...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
|
|
419
|
+
...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
|
|
420
|
+
...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
|
|
421
|
+
...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
|
|
422
|
+
...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
|
|
423
|
+
...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
|
|
424
|
+
...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
|
|
425
|
+
...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
|
|
426
|
+
...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
|
|
427
|
+
...(sort ? { sort } : {}),
|
|
428
|
+
...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
|
|
429
|
+
...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
|
|
430
|
+
...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
|
|
431
|
+
...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
|
|
432
|
+
...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
|
|
433
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
434
|
+
...(showLegend !== undefined ? { showLegend } : {}),
|
|
435
|
+
...(showLabels !== undefined ? { showLabels } : {}),
|
|
436
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
437
|
+
...(x !== undefined ? { x } : {}),
|
|
438
|
+
...(y !== undefined ? { y } : {}),
|
|
439
|
+
...(width !== undefined ? { width } : {}),
|
|
440
|
+
...(nodeHeight !== undefined ? { heightPx: nodeHeight } : {}),
|
|
441
|
+
});
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (error instanceof OperationError) throw error;
|
|
444
|
+
throw new OperationError(error instanceof Error ? error.message : String(error));
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
serialize: (result) => ({ ...buildNodeResponse(result.node), url: result.url, spec: result.spec }),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ── schema.describe ───────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
const schemaDescribeShape = {};
|
|
453
|
+
|
|
454
|
+
const schemaDescribeSchema = z.looseObject(schemaDescribeShape);
|
|
455
|
+
|
|
456
|
+
const schemaDescribeOperation = defineOperation<
|
|
457
|
+
z.infer<typeof schemaDescribeSchema>,
|
|
458
|
+
ReturnType<typeof describeCanvasSchema>
|
|
459
|
+
>({
|
|
460
|
+
name: 'schema.describe',
|
|
461
|
+
mutates: false,
|
|
462
|
+
input: schemaDescribeSchema,
|
|
463
|
+
inputShape: schemaDescribeShape,
|
|
464
|
+
http: {
|
|
465
|
+
method: 'GET',
|
|
466
|
+
path: '/api/canvas/schema',
|
|
467
|
+
},
|
|
468
|
+
mcp: {
|
|
469
|
+
toolName: 'canvas_describe_schema',
|
|
470
|
+
description: 'Describe the current server-supported canvas create schemas, json-render component catalog, canonical examples, and related MCP entry points. Includes mcp.nodeTypeRouting, the authoritative map from node type to MCP creation tool.',
|
|
471
|
+
formatResult: (result) => ({
|
|
472
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
473
|
+
}),
|
|
474
|
+
},
|
|
475
|
+
handler: () => describeCanvasSchema(),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// ── spec.validate ─────────────────────────────────────────────
|
|
479
|
+
|
|
480
|
+
const specValidateShape = {
|
|
481
|
+
type: z.unknown().optional().describe('Structured payload type to validate'),
|
|
482
|
+
spec: z.unknown().optional().describe('json-render spec to validate when type="json-render"'),
|
|
483
|
+
kind: z.unknown().optional().describe('HTML primitive kind when type="html-primitive"'),
|
|
484
|
+
primitive: z.unknown().optional().describe('Alias for kind when type="html-primitive"'),
|
|
485
|
+
// MCP names the primitive payload `primitiveData`; the HTTP body uses `data`
|
|
486
|
+
// (which doubles as the graph dataset field). buildInput maps one onto the other.
|
|
487
|
+
primitiveData: z.unknown().optional().describe('HTML primitive data payload when type="html-primitive"'),
|
|
488
|
+
data: z.unknown().optional().describe('Graph dataset when type="graph"'),
|
|
489
|
+
title: z.string().optional().catch(undefined).describe('Optional graph title'),
|
|
490
|
+
graphType: z.string().optional().catch(undefined).describe('Graph type when type="graph"'),
|
|
491
|
+
xKey: z.string().optional().catch(undefined).describe('X-axis key for line/bar graphs'),
|
|
492
|
+
yKey: z.string().optional().catch(undefined).describe('Y-axis key for line/bar graphs'),
|
|
493
|
+
zKey: z.string().optional().catch(undefined).describe('Optional bubble-size key for scatter charts'),
|
|
494
|
+
nameKey: z.string().optional().catch(undefined).describe('Slice name key for pie graphs'),
|
|
495
|
+
valueKey: z.string().optional().catch(undefined).describe('Value key for pie slices, sparkline, dot-plot, and the bullet measure'),
|
|
496
|
+
axisKey: z.string().optional().catch(undefined).describe('Category key for radar charts'),
|
|
497
|
+
metrics: z.array(z.string()).optional().catch(undefined).describe('Series keys to plot as radar polygons'),
|
|
498
|
+
series: z.array(z.string()).optional().catch(undefined).describe('Series keys for stacked-bar segments'),
|
|
499
|
+
barKey: z.string().optional().catch(undefined).describe('Bar series key for composed charts'),
|
|
500
|
+
lineKey: z.string().optional().catch(undefined).describe('Line series key for composed charts'),
|
|
501
|
+
aggregate: z.enum(['sum', 'count', 'avg']).optional().catch(undefined).describe('Optional aggregation for repeated keys'),
|
|
502
|
+
color: z.string().optional().catch(undefined).describe('Optional graph color'),
|
|
503
|
+
colorBy: z.enum(['series', 'category', 'value', 'none']).optional().catch(undefined).describe("Bar charts only: how bars are colored (default 'series')"),
|
|
504
|
+
highlight: z.union([z.number(), z.enum(['max', 'min'])]).nullable().optional().catch(undefined).describe("Bar charts only, colorBy='series': which bar gets the accent"),
|
|
505
|
+
barColor: z.string().optional().catch(undefined).describe('Optional bar color for composed charts'),
|
|
506
|
+
lineColor: z.string().optional().catch(undefined).describe('Optional line color for composed charts'),
|
|
507
|
+
labelKey: z.string().optional().catch(undefined).describe('Category label key for dot-plot / bullet / slopegraph rows'),
|
|
508
|
+
targetKey: z.string().optional().catch(undefined).describe('Per-row target value key for bullet charts'),
|
|
509
|
+
rangesKey: z.string().optional().catch(undefined).describe('Per-row qualitative band thresholds key (number[]) for bullet charts'),
|
|
510
|
+
beforeKey: z.string().optional().catch(undefined).describe('Left-column value key for slopegraph'),
|
|
511
|
+
afterKey: z.string().optional().catch(undefined).describe('Right-column value key for slopegraph'),
|
|
512
|
+
beforeLabel: z.string().optional().catch(undefined).describe('Header label for the slopegraph left column'),
|
|
513
|
+
afterLabel: z.string().optional().catch(undefined).describe('Header label for the slopegraph right column'),
|
|
514
|
+
sort: z.enum(['asc', 'desc', 'none']).optional().catch(undefined).describe('Row sort order for dot-plot (defaults to desc)'),
|
|
515
|
+
fill: z.boolean().optional().catch(undefined).describe('Sparkline: draw a light area fill under the line'),
|
|
516
|
+
showEndDot: z.boolean().optional().catch(undefined).describe('Sparkline: draw a dot at the last point (default true)'),
|
|
517
|
+
showMinMax: z.boolean().optional().catch(undefined).describe('Sparkline: mark the min and max points'),
|
|
518
|
+
showValue: z.boolean().optional().catch(undefined).describe('Sparkline: print the last value inline'),
|
|
519
|
+
colorByDirection: z.boolean().optional().catch(undefined).describe('Slopegraph: accent rising lines and mute falling ones (default off)'),
|
|
520
|
+
height: z.number().optional().catch(undefined).describe('Optional graph content height'),
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const specValidateSchema = z.looseObject(specValidateShape);
|
|
524
|
+
|
|
525
|
+
const specValidateOperation = defineOperation<
|
|
526
|
+
z.infer<typeof specValidateSchema>,
|
|
527
|
+
Record<string, unknown>
|
|
528
|
+
>({
|
|
529
|
+
name: 'spec.validate',
|
|
530
|
+
mutates: false,
|
|
531
|
+
input: specValidateSchema,
|
|
532
|
+
inputShape: specValidateShape,
|
|
533
|
+
http: {
|
|
534
|
+
method: 'POST',
|
|
535
|
+
path: '/api/canvas/schema/validate',
|
|
536
|
+
// Legacy: validation throws become `{ ok:false, error, type }` with 400.
|
|
537
|
+
status: (result) => (isRecord(result) && result.ok === false ? 400 : 200),
|
|
538
|
+
},
|
|
539
|
+
mcp: {
|
|
540
|
+
toolName: 'canvas_validate_spec',
|
|
541
|
+
description: 'Validate a json-render spec, graph payload, or HTML primitive payload without creating a node. Returns normalized metadata the server would accept.',
|
|
542
|
+
extraShape: {
|
|
543
|
+
type: z.enum(['json-render', 'graph', 'html-primitive']).describe('Structured payload type to validate'),
|
|
544
|
+
spec: jsonRenderSpecSchema.optional().describe('json-render spec to validate when type="json-render"'),
|
|
545
|
+
kind: htmlPrimitiveKindSchema.optional().describe('HTML primitive kind when type="html-primitive"'),
|
|
546
|
+
primitive: htmlPrimitiveKindSchema.optional().describe('Alias for kind when type="html-primitive"'),
|
|
547
|
+
primitiveData: z.record(z.string(), z.unknown()).optional().describe('HTML primitive data payload when type="html-primitive"'),
|
|
548
|
+
data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
|
|
549
|
+
},
|
|
550
|
+
buildInput: (input) => {
|
|
551
|
+
if (input.type === 'html-primitive') {
|
|
552
|
+
const { primitiveData, ...rest } = input;
|
|
553
|
+
return { ...rest, ...(primitiveData !== undefined ? { data: primitiveData } : {}) };
|
|
554
|
+
}
|
|
555
|
+
if (input.type === 'graph') {
|
|
556
|
+
// Legacy MCP defaulted a missing graph dataset to [].
|
|
557
|
+
return { ...input, data: input.data ?? [] };
|
|
558
|
+
}
|
|
559
|
+
return input;
|
|
560
|
+
},
|
|
561
|
+
formatResult: (result) => {
|
|
562
|
+
const body = isRecord(result) ? result : {};
|
|
563
|
+
if (body.ok === false) {
|
|
564
|
+
// Legacy MCP surfaced the bare validation message with isError.
|
|
565
|
+
return {
|
|
566
|
+
content: [{ type: 'text' as const, text: typeof body.error === 'string' ? body.error : 'Validation failed.' }],
|
|
567
|
+
isError: true,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
handler: (input) => {
|
|
574
|
+
const body: Record<string, unknown> = input;
|
|
575
|
+
const rawType = typeof body.type === 'string' ? body.type.trim() : '';
|
|
576
|
+
if (rawType !== 'json-render' && rawType !== 'graph' && rawType !== 'html-primitive') {
|
|
577
|
+
throw new OperationError('Validation type must be "json-render", "graph", or "html-primitive".');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
if (rawType === 'json-render') {
|
|
582
|
+
const rawSpec =
|
|
583
|
+
body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec)
|
|
584
|
+
? body.spec
|
|
585
|
+
: body;
|
|
586
|
+
return validateStructuredCanvasPayload({
|
|
587
|
+
type: 'json-render',
|
|
588
|
+
spec: rawSpec,
|
|
589
|
+
}) as unknown as Record<string, unknown>;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (rawType === 'html-primitive') {
|
|
593
|
+
const kind = typeof body.kind === 'string'
|
|
594
|
+
? body.kind
|
|
595
|
+
: typeof body.primitive === 'string'
|
|
596
|
+
? body.primitive
|
|
597
|
+
: '';
|
|
598
|
+
const data = isRecord(body.data) ? body.data : {};
|
|
599
|
+
return validateStructuredCanvasPayload({
|
|
600
|
+
type: 'html-primitive',
|
|
601
|
+
primitive: {
|
|
602
|
+
kind,
|
|
603
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
604
|
+
data,
|
|
605
|
+
},
|
|
606
|
+
}) as unknown as Record<string, unknown>;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const data = parseGraphPayloadData(body.data);
|
|
610
|
+
if (!data) {
|
|
611
|
+
throw new OperationError('Graph validation requires a data array.');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const aggregate =
|
|
615
|
+
body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
|
|
616
|
+
? body.aggregate
|
|
617
|
+
: undefined;
|
|
618
|
+
const colorBy =
|
|
619
|
+
body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
|
|
620
|
+
? body.colorBy
|
|
621
|
+
: undefined;
|
|
622
|
+
const highlight =
|
|
623
|
+
typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
|
|
624
|
+
? body.highlight
|
|
625
|
+
: undefined;
|
|
626
|
+
const sort =
|
|
627
|
+
body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
|
|
628
|
+
|
|
629
|
+
return validateStructuredCanvasPayload({
|
|
630
|
+
type: 'graph',
|
|
631
|
+
graph: {
|
|
632
|
+
title: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph',
|
|
633
|
+
// `typeName` is the legacy HTTP-only graphType alias on this route.
|
|
634
|
+
graphType: typeof body.graphType === 'string'
|
|
635
|
+
? body.graphType
|
|
636
|
+
: typeof body.typeName === 'string'
|
|
637
|
+
? body.typeName
|
|
638
|
+
: 'line',
|
|
639
|
+
data,
|
|
640
|
+
...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
|
|
641
|
+
...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
|
|
642
|
+
...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
|
|
643
|
+
...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
|
|
644
|
+
...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
|
|
645
|
+
...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
|
|
646
|
+
...(Array.isArray(body.metrics)
|
|
647
|
+
? { metrics: body.metrics.filter((m: unknown): m is string => typeof m === 'string') }
|
|
648
|
+
: {}),
|
|
649
|
+
...(Array.isArray(body.series)
|
|
650
|
+
? { series: body.series.filter((s: unknown): s is string => typeof s === 'string') }
|
|
651
|
+
: {}),
|
|
652
|
+
...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
|
|
653
|
+
...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
|
|
654
|
+
...(aggregate ? { aggregate } : {}),
|
|
655
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
656
|
+
...(colorBy ? { colorBy } : {}),
|
|
657
|
+
...(highlight !== undefined ? { highlight } : {}),
|
|
658
|
+
...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
|
|
659
|
+
...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
|
|
660
|
+
...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
|
|
661
|
+
...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
|
|
662
|
+
...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
|
|
663
|
+
...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
|
|
664
|
+
...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
|
|
665
|
+
...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
|
|
666
|
+
...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
|
|
667
|
+
...(sort ? { sort } : {}),
|
|
668
|
+
...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
|
|
669
|
+
...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
|
|
670
|
+
...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
|
|
671
|
+
...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
|
|
672
|
+
...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
|
|
673
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
674
|
+
},
|
|
675
|
+
}) as unknown as Record<string, unknown>;
|
|
676
|
+
} catch (error) {
|
|
677
|
+
if (error instanceof OperationError) throw error;
|
|
678
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
679
|
+
// Legacy HTTP error body includes the requested `type` — clients parse it.
|
|
680
|
+
return { ok: false, error: message, type: rawType };
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
export const jsonRenderOperations: Operation[] = [
|
|
686
|
+
jsonRenderAddOperation,
|
|
687
|
+
jsonRenderStreamOperation,
|
|
688
|
+
graphAddOperation,
|
|
689
|
+
schemaDescribeOperation,
|
|
690
|
+
specValidateOperation,
|
|
691
|
+
];
|