pmx-canvas 0.1.35 → 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';
@@ -1758,7 +1761,15 @@ async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<R
1758
1761
  async function handleCanvasAddNode(req: Request): Promise<Response> {
1759
1762
  const body = await readJson(req);
1760
1763
  const queryType = new URL(req.url).searchParams.get('type');
1761
- 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
+ }
1762
1773
 
1763
1774
  if (!VALID_NODE_TYPES.has(type)) {
1764
1775
  if (type === 'json-render') {
@@ -1824,8 +1835,11 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1824
1835
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1825
1836
  ? body.path
1826
1837
  : 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`.
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;
1829
1843
  const htmlMergedData = type === 'html'
1830
1844
  ? {
1831
1845
  ...(extraData ?? {}),
@@ -1837,6 +1851,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1837
1851
  ...(Array.isArray(body.slideTitles) ? { slideTitles: body.slideTitles } : {}),
1838
1852
  ...(Array.isArray(body.embeddedNodeIds) ? { embeddedNodeIds: body.embeddedNodeIds } : {}),
1839
1853
  ...(Array.isArray(body.embeddedUrls) ? { embeddedUrls: body.embeddedUrls } : {}),
1854
+ ...(topAxCapabilities ? { axCapabilities: topAxCapabilities } : {}),
1840
1855
  }
1841
1856
  : extraData;
1842
1857
  let added: ReturnType<typeof addCanvasNode>;
@@ -2105,7 +2120,8 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
2105
2120
  body.data ||
2106
2121
  typeof body.arrangeLocked === 'boolean' ||
2107
2122
  typeof body.strictSize === 'boolean' ||
2108
- (existing.type === 'trace' && hasTraceNodeDataFields(body))
2123
+ (existing.type === 'trace' && hasTraceNodeDataFields(body)) ||
2124
+ (existing.type === 'html' && (body.html !== undefined || body.axCapabilities !== undefined))
2109
2125
  ) {
2110
2126
  const data = { ...existing.data };
2111
2127
  if (body.title !== undefined) {
@@ -2121,6 +2137,18 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
2121
2137
  if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
2122
2138
  Object.assign(data, body.data as Record<string, unknown>);
2123
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
+ }
2124
2152
  if (existing.type === 'webpage') {
2125
2153
  const nextUrl = typeof body.url === 'string'
2126
2154
  ? body.url
@@ -3912,8 +3940,132 @@ function handleGetAxState(): Response {
3912
3940
  return responseJson({ ok: true, state: canvasState.getAxState() });
3913
3941
  }
3914
3942
 
3915
- function handleGetAxContext(): Response {
3916
- 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 });
3917
4069
  }
3918
4070
 
3919
4071
  // Compact AX state for surfaces (the same shape seeded into AX-enabled iframes).
@@ -4172,6 +4324,11 @@ async function handleAxWorkAdd(req: Request): Promise<Response> {
4172
4324
  if (typeof body.title !== 'string' || !body.title.trim()) {
4173
4325
  return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
4174
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
+ }
4175
4332
  const status = normalizeAxWorkItemStatus(body.status);
4176
4333
  const workItem = canvasState.addWorkItem(
4177
4334
  {
@@ -4192,6 +4349,10 @@ async function handleAxWorkAdd(req: Request): Promise<Response> {
4192
4349
 
4193
4350
  async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
4194
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
+ }
4195
4356
  const status = normalizeAxWorkItemStatus(body.status);
4196
4357
  const workItem = canvasState.updateWorkItem(
4197
4358
  id,
@@ -5422,7 +5583,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5422
5583
  }
5423
5584
 
5424
5585
  if (url.pathname === '/api/canvas/ax/context' && req.method === 'GET') {
5425
- return handleGetAxContext();
5586
+ return handleGetAxContext(url);
5587
+ }
5588
+
5589
+ if (url.pathname === '/api/canvas/ax/activity' && req.method === 'POST') {
5590
+ return handleAxActivityIngest(req);
5426
5591
  }
5427
5592
 
5428
5593
  if (url.pathname === '/api/canvas/ax/surface-snapshot' && req.method === 'GET') {
@@ -5477,6 +5642,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5477
5642
  return handleAxApprovalResolve(req, approvalId);
5478
5643
  }
5479
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
+
5480
5650
  if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
5481
5651
  return handleAxEvidenceAdd(req);
5482
5652
  }
@@ -5532,6 +5702,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5532
5702
  return handleAxElicitationRespond(req, elicitationId);
5533
5703
  }
5534
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
+
5535
5710
  if (url.pathname === '/api/canvas/ax/mode' && req.method === 'GET') {
5536
5711
  return handleAxModeList();
5537
5712
  }
@@ -5547,6 +5722,11 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
5547
5722
  return handleAxModeResolve(req, modeId);
5548
5723
  }
5549
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
+
5550
5730
  if (url.pathname === '/api/canvas/ax/command' && req.method === 'GET') {
5551
5731
  return handleAxCommandList();
5552
5732
  }