pmx-canvas 0.1.34 → 0.1.36

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.
@@ -78,9 +78,11 @@ import {
78
78
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
79
79
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
80
80
  import { buildCanvasAxContext, buildCanvasAxSurfaceSnapshot } from './ax-context.js';
81
- import { applyAxInteraction, resolveNodeAxCapabilities } from './ax-interaction.js';
82
- import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
81
+ import { applyAxInteraction, resolveNodeAxCapabilities, normalizeNodeAxCapabilities } from './ax-interaction.js';
82
+ import { isAxEventKind, isAxEvidenceKind, isAxActivityKind } from './ax-state.js';
83
+ import { waitForAxResolution, AX_WAIT_MAX_MS } from './ax-wait.js';
83
84
  import type {
85
+ PmxAxEvidenceKind,
84
86
  PmxAxPolicy,
85
87
  PmxAxReviewAnchorType,
86
88
  PmxAxReviewKind,
@@ -88,6 +90,7 @@ import type {
88
90
  PmxAxReviewSeverity,
89
91
  PmxAxReviewStatus,
90
92
  PmxAxSource,
93
+ PmxAxWorkItemStatus,
91
94
  } from './ax-state.js';
92
95
  import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
93
96
  import { validateLocalImageFile } from './image-source.js';
@@ -1072,6 +1075,34 @@ async function readJson(req: Request): Promise<Record<string, unknown>> {
1072
1075
  }
1073
1076
  }
1074
1077
 
1078
+ /**
1079
+ * Like {@link readJson}, but PRESERVES a top-level JSON array. For endpoints that
1080
+ * accept either an object or a bare array (e.g. `/api/canvas/batch`, whose CLI
1081
+ * help and handler both document a bare `[...]` form). readJson coerces arrays to
1082
+ * `{}` so object-shaped handlers never crash on `body.field`; this variant keeps
1083
+ * the array so the handler's array branch can run. Empty/whitespace/malformed
1084
+ * bodies still resolve to `{}`.
1085
+ */
1086
+ async function readJsonObjectOrArray(req: Request): Promise<Record<string, unknown> | unknown[]> {
1087
+ let text = '';
1088
+ try {
1089
+ text = await req.text();
1090
+ } catch (error) {
1091
+ logWorkbenchWarning('readJson', error);
1092
+ return {};
1093
+ }
1094
+ if (!text.trim()) return {};
1095
+ try {
1096
+ const value = JSON.parse(text) as unknown;
1097
+ if (Array.isArray(value)) return value;
1098
+ if (!value || typeof value !== 'object') return {};
1099
+ return value as Record<string, unknown>;
1100
+ } catch (error) {
1101
+ logWorkbenchWarning('readJson', error);
1102
+ return {};
1103
+ }
1104
+ }
1105
+
1075
1106
  function htmlEscape(value: string): string {
1076
1107
  return value
1077
1108
  .replaceAll('&', '&amp;')
@@ -1730,7 +1761,15 @@ async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<R
1730
1761
  async function handleCanvasAddNode(req: Request): Promise<Response> {
1731
1762
  const body = await readJson(req);
1732
1763
  const queryType = new URL(req.url).searchParams.get('type');
1733
- const type = typeof body.type === 'string' ? body.type : queryType || 'markdown';
1764
+ // Report #50: require a resolvable type rather than silently defaulting to a
1765
+ // markdown node — an empty / type-less body created a phantom node before.
1766
+ const type = typeof body.type === 'string' ? body.type : (queryType || '');
1767
+ if (!type) {
1768
+ return responseJson({
1769
+ ok: false,
1770
+ error: `node creation requires a 'type' — pass it in the JSON body ({ "type": "markdown", ... }) or as a ?type= query param. Valid types: ${[...VALID_NODE_TYPES].join(', ')} (json-render / graph / web-artifact have dedicated endpoints).`,
1771
+ }, 400);
1772
+ }
1734
1773
 
1735
1774
  if (!VALID_NODE_TYPES.has(type)) {
1736
1775
  if (type === 'json-render') {
@@ -1796,8 +1835,11 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1796
1835
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1797
1836
  ? body.path
1798
1837
  : body.content;
1799
- // For html nodes, accept top-level `html` field and merge into data so callers
1800
- // can POST { type: 'html', title, html } without nesting under `data`.
1838
+ // For html nodes, accept top-level `html` AND `axCapabilities` and merge into data
1839
+ // so callers can POST { type: 'html', title, html, axCapabilities } without nesting
1840
+ // under `data` (report #53 — transport parity with MCP canvas_add_html_node). A
1841
+ // top-level value overrides the same key under `data` (mirrors the `html` precedence).
1842
+ const topAxCapabilities = type === 'html' ? normalizeNodeAxCapabilities(body.axCapabilities) : null;
1801
1843
  const htmlMergedData = type === 'html'
1802
1844
  ? {
1803
1845
  ...(extraData ?? {}),
@@ -1809,6 +1851,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1809
1851
  ...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
1810
1852
  ...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
1811
1853
  ...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
1854
+ ...(topAxCapabilities ? { axCapabilities: topAxCapabilities } : {}),
1812
1855
  }
1813
1856
  : extraData;
1814
1857
  let added: ReturnType<typeof addCanvasNode>;
@@ -2077,7 +2120,8 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
2077
2120
  body.data ||
2078
2121
  typeof body.arrangeLocked === 'boolean' ||
2079
2122
  typeof body.strictSize === 'boolean' ||
2080
- (existing.type === 'trace' && hasTraceNodeDataFields(body))
2123
+ (existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
2124
+ (existing.type === 'html' && (body.html !== undefined || body.axCapabilities !== undefined))
2081
2125
  ) {
2082
2126
  const data = { ...existing.data };
2083
2127
  if (body.title !== undefined) {
@@ -2093,6 +2137,18 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
2093
2137
  if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
2094
2138
  Object.assign(data, body.data as Record<string, unknown>);
2095
2139
  }
2140
+ // Report #53: for html nodes, accept top-level `html` / `axCapabilities` on PATCH
2141
+ // too (top-level overrides the `data.*` merge above — matches POST + MCP parity).
2142
+ if (existing.type === 'html') {
2143
+ if (body.html !== undefined) {
2144
+ if (typeof body.html !== 'string') {
2145
+ return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
2146
+ }
2147
+ data.html = resolveHtmlContent(body.html);
2148
+ }
2149
+ const patchAxCapabilities = normalizeNodeAxCapabilities(body.axCapabilities);
2150
+ if (patchAxCapabilities) data.axCapabilities = patchAxCapabilities;
2151
+ }
2096
2152
  if (existing.type === 'webpage') {
2097
2153
  const nextUrl = typeof body.url === 'string'
2098
2154
  ? body.url
@@ -2495,8 +2551,12 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
2495
2551
  }
2496
2552
 
2497
2553
  async function handleCanvasBatch(req: Request): Promise<Response> {
2498
- const body = await readJson(req);
2499
- const operations = Array.isArray(body.operations) ? body.operations : Array.isArray(body) ? body : [];
2554
+ // Accept both documented shapes: { operations: [...] } and a bare [...] array.
2555
+ // Uses the array-preserving reader so the bare-array form isn't coerced to {}.
2556
+ const body = await readJsonObjectOrArray(req);
2557
+ const operations = Array.isArray(body)
2558
+ ? body
2559
+ : Array.isArray(body.operations) ? body.operations : [];
2500
2560
  const normalized = operations
2501
2561
  .filter((operation): operation is Record<string, unknown> => operation && typeof operation === 'object' && !Array.isArray(operation))
2502
2562
  .map((operation) => ({
@@ -3880,8 +3940,132 @@ function handleGetAxState(): Response {
3880
3940
  return responseJson({ ok: true, state: canvasState.getAxState() });
3881
3941
  }
3882
3942
 
3883
- function handleGetAxContext(): Response {
3884
- return responseJson(buildCanvasAxContext());
3943
+ function handleGetAxContext(url: URL): Response {
3944
+ // Optional ?consumer= filters the compact `delivery` lead block (loop-safe — a
3945
+ // consumer never sees steering/activity it originated), so a host adapter can
3946
+ // inject its own un-truncated pending block per turn (report #54 hardening).
3947
+ const consumer = url.searchParams.get('consumer') ?? undefined;
3948
+ return responseJson(buildCanvasAxContext(consumer));
3949
+ }
3950
+
3951
+ // Clamp ?waitMs= to [0, AX_WAIT_MAX_MS]. 0 (or absent/NaN) = a plain single read.
3952
+ function parseAxWaitMs(url: URL): number {
3953
+ const raw = Number(url.searchParams.get('waitMs') ?? '');
3954
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, AX_WAIT_MAX_MS) : 0;
3955
+ }
3956
+
3957
+ function isReviewSeverity(v: unknown): v is PmxAxReviewSeverity {
3958
+ return v === 'info' || v === 'warning' || v === 'error';
3959
+ }
3960
+ function isReviewKind(v: unknown): v is PmxAxReviewKind {
3961
+ return v === 'comment' || v === 'finding';
3962
+ }
3963
+ function isReviewAnchor(v: unknown): v is PmxAxReviewAnchorType {
3964
+ return v === 'node' || v === 'file' || v === 'region';
3965
+ }
3966
+
3967
+ // Validate untrusted activity `reactions` from an HTTP body into the typed override
3968
+ // shape ingestActivity expects. `false` suppresses a default reaction; an object
3969
+ // overrides its fields (invalid fields are dropped, not stored raw).
3970
+ function normalizeActivityReactions(input: Record<string, unknown>): {
3971
+ workItem?: false | { status?: PmxAxWorkItemStatus; detail?: string | null };
3972
+ evidence?: false | { kind?: PmxAxEvidenceKind; body?: string | null };
3973
+ review?: false | { severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind; anchorType?: PmxAxReviewAnchorType; nodeId?: string | null };
3974
+ } {
3975
+ const out: ReturnType<typeof normalizeActivityReactions> = {};
3976
+ if (input.workItem === false) out.workItem = false;
3977
+ else if (isRecord(input.workItem)) {
3978
+ const status = normalizeAxWorkItemStatus(input.workItem.status);
3979
+ out.workItem = {
3980
+ ...(status ? { status } : {}),
3981
+ ...(typeof input.workItem.detail === 'string' ? { detail: input.workItem.detail } : {}),
3982
+ };
3983
+ }
3984
+ if (input.evidence === false) out.evidence = false;
3985
+ else if (isRecord(input.evidence)) {
3986
+ out.evidence = {
3987
+ ...(isAxEvidenceKind(input.evidence.kind) ? { kind: input.evidence.kind } : {}),
3988
+ ...(typeof input.evidence.body === 'string' ? { body: input.evidence.body } : {}),
3989
+ };
3990
+ }
3991
+ if (input.review === false) out.review = false;
3992
+ else if (isRecord(input.review)) {
3993
+ out.review = {
3994
+ ...(isReviewSeverity(input.review.severity) ? { severity: input.review.severity } : {}),
3995
+ ...(isReviewKind(input.review.kind) ? { kind: input.review.kind } : {}),
3996
+ ...(isReviewAnchor(input.review.anchorType) ? { anchorType: input.review.anchorType } : {}),
3997
+ ...(typeof input.review.nodeId === 'string' ? { nodeId: input.review.nodeId } : {}),
3998
+ };
3999
+ }
4000
+ return out;
4001
+ }
4002
+
4003
+ // Report primitive A: ingest a harness-forwarded agent activity; the board auto-reacts.
4004
+ async function handleAxActivityIngest(req: Request): Promise<Response> {
4005
+ const body = await readJson(req);
4006
+ if (!isAxActivityKind(body.kind)) {
4007
+ 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);
4008
+ }
4009
+ if (typeof body.title !== 'string' || !body.title.trim()) {
4010
+ return responseJson({ ok: false, error: 'activity requires a title.' }, 400);
4011
+ }
4012
+ const result = canvasState.ingestActivity(
4013
+ {
4014
+ kind: body.kind,
4015
+ title: body.title,
4016
+ ...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
4017
+ ...(body.outcome === 'success' || body.outcome === 'failure' ? { outcome: body.outcome } : {}),
4018
+ ...(typeof body.ref === 'string' ? { ref: body.ref } : {}),
4019
+ ...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
4020
+ ...(isRecord(body.data) ? { data: body.data } : {}),
4021
+ ...(isRecord(body.reactions) ? { reactions: normalizeActivityReactions(body.reactions) } : {}),
4022
+ },
4023
+ { source: normalizeAxSource(body.source, 'api') },
4024
+ );
4025
+ const meta = { sessionId: primaryWorkbenchSessionId, timestamp: new Date().toISOString() };
4026
+ broadcastWorkbenchEvent('ax-event-created', { event: result.event, ...meta });
4027
+ if (result.workItem) broadcastWorkbenchEvent('ax-state-changed', { workItem: result.workItem, ...meta });
4028
+ if (result.evidence) broadcastWorkbenchEvent('ax-event-created', { evidence: result.evidence, ...meta });
4029
+ if (result.review) broadcastWorkbenchEvent('ax-state-changed', { reviewAnnotation: result.review, ...meta });
4030
+ return responseJson({ ok: true, ...result });
4031
+ }
4032
+
4033
+ // Report primitive D: single-item read of a gate, with optional ?waitMs= long-poll
4034
+ // that resolves when the human resolves it in the browser (gates that actually gate).
4035
+ async function handleAxApprovalGet(url: URL, id: string, req: Request): Promise<Response> {
4036
+ const waitMs = parseAxWaitMs(url);
4037
+ const { value, pending } = await waitForAxResolution({
4038
+ read: () => canvasState.getApproval(id),
4039
+ isResolved: (g) => g.status !== 'pending',
4040
+ timeoutMs: waitMs,
4041
+ signal: req.signal,
4042
+ });
4043
+ if (!value) return responseJson({ ok: false, error: 'approval gate not found.' }, 404);
4044
+ return responseJson({ ok: true, approvalGate: value, pending });
4045
+ }
4046
+
4047
+ async function handleAxElicitationGet(url: URL, id: string, req: Request): Promise<Response> {
4048
+ const waitMs = parseAxWaitMs(url);
4049
+ const { value, pending } = await waitForAxResolution({
4050
+ read: () => canvasState.getElicitation(id),
4051
+ isResolved: (e) => e.status !== 'pending',
4052
+ timeoutMs: waitMs,
4053
+ signal: req.signal,
4054
+ });
4055
+ if (!value) return responseJson({ ok: false, error: 'elicitation not found.' }, 404);
4056
+ return responseJson({ ok: true, elicitation: value, pending });
4057
+ }
4058
+
4059
+ async function handleAxModeGet(url: URL, id: string, req: Request): Promise<Response> {
4060
+ const waitMs = parseAxWaitMs(url);
4061
+ const { value, pending } = await waitForAxResolution({
4062
+ read: () => canvasState.getModeRequest(id),
4063
+ isResolved: (m) => m.status !== 'pending',
4064
+ timeoutMs: waitMs,
4065
+ signal: req.signal,
4066
+ });
4067
+ if (!value) return responseJson({ ok: false, error: 'mode request not found.' }, 404);
4068
+ return responseJson({ ok: true, modeRequest: value, pending });
3885
4069
  }
3886
4070
 
3887
4071
  // Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
@@ -4140,6 +4324,11 @@ async function handleAxWorkAdd(req: Request): Promise<Response> {
4140
4324
  if (typeof body.title !== 'string' || !body.title.trim()) {
4141
4325
  return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
4142
4326
  }
4327
+ // Report #56: reject an unknown status (e.g. "in_progress") instead of silently
4328
+ // dropping it — the accepted tokens use hyphens.
4329
+ if (body.status !== undefined && !normalizeAxWorkItemStatus(body.status)) {
4330
+ return responseJson({ ok: false, error: `invalid work item status "${String(body.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.` }, 400);
4331
+ }
4143
4332
  const status = normalizeAxWorkItemStatus(body.status);
4144
4333
  const workItem = canvasState.addWorkItem(
4145
4334
  {
@@ -4160,6 +4349,10 @@ async function handleAxWorkAdd(req: Request): Promise<Response> {
4160
4349
 
4161
4350
  async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
4162
4351
  const body = await readJson(req);
4352
+ // Report #56: reject an unknown status instead of returning ok:true + no-op.
4353
+ if (body.status !== undefined && !normalizeAxWorkItemStatus(body.status)) {
4354
+ return responseJson({ ok: false, error: `invalid work item status "${String(body.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.` }, 400);
4355
+ }
4163
4356
  const status = normalizeAxWorkItemStatus(body.status);
4164
4357
  const workItem = canvasState.updateWorkItem(
4165
4358
  id,
@@ -5390,7 +5583,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5390
5583
  }
5391
5584
 
5392
5585
  if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
5393
- return handleGetAxContext();
5586
+ return handleGetAxContext(url);
5587
+ }
5588
+
5589
+ if (url.pathname === '/api/canvas/ax/activity' && req.method === 'POST') {
5590
+ return handleAxActivityIngest(req);
5394
5591
  }
5395
5592
 
5396
5593
  if (url.pathname === '/api/canvas/ax/surface-snapshot' && req.method === 'GET') {
@@ -5445,6 +5642,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5445
5642
  return handleAxApprovalResolve(req, approvalId);
5446
5643
  }
5447
5644
 
5645
+ if (url.pathname.startsWith('/api/canvas/ax/approval/') && !url.pathname.endsWith('/resolve') && req.method === 'GET') {
5646
+ const approvalId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/approval/'.length));
5647
+ return handleAxApprovalGet(url, approvalId, req);
5648
+ }
5649
+
5448
5650
  if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
5449
5651
  return handleAxEvidenceAdd(req);
5450
5652
  }
@@ -5500,6 +5702,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5500
5702
  return handleAxElicitationRespond(req, elicitationId);
5501
5703
  }
5502
5704
 
5705
+ if (url.pathname.startsWith('/api/canvas/ax/elicitation/') && !url.pathname.endsWith('/respond') && req.method === 'GET') {
5706
+ const elicitationId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/elicitation/'.length));
5707
+ return handleAxElicitationGet(url, elicitationId, req);
5708
+ }
5709
+
5503
5710
  if (url.pathname === '/api/canvas/ax/mode' && req.method === 'GET') {
5504
5711
  return handleAxModeList();
5505
5712
  }
@@ -5515,6 +5722,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5515
5722
  return handleAxModeResolve(req, modeId);
5516
5723
  }
5517
5724
 
5725
+ if (url.pathname.startsWith('/api/canvas/ax/mode/') && !url.pathname.endsWith('/resolve') && req.method === 'GET') {
5726
+ const modeId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/mode/'.length));
5727
+ return handleAxModeGet(url, modeId, req);
5728
+ }
5729
+
5518
5730
  if (url.pathname === '/api/canvas/ax/command' && req.method === 'GET') {
5519
5731
  return handleAxCommandList();
5520
5732
  }