pmx-canvas 0.1.29 → 0.1.31
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 +219 -0
- package/Readme.md +20 -10
- package/dist/canvas/global.css +51 -56
- package/dist/canvas/index.js +80 -163
- package/dist/canvas/surface-theme.css +142 -0
- package/dist/json-render/index.js +103 -103
- package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
- package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
- package/dist/types/client/nodes/surface-url.d.ts +22 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +17 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -0
- package/dist/types/json-render/schema.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +47 -0
- package/dist/types/server/ax-interaction.d.ts +210 -0
- package/dist/types/server/ax-state.d.ts +67 -1
- package/dist/types/server/canvas-db.d.ts +4 -0
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +54 -2
- package/dist/types/server/html-surface.d.ts +46 -0
- package/dist/types/server/index.d.ts +63 -5
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/placement.d.ts +1 -1
- package/dist/types/server/server.d.ts +12 -0
- package/dist/types/shared/surface.d.ts +19 -0
- package/docs/cli.md +30 -0
- package/docs/http-api.md +55 -0
- package/docs/mcp.md +40 -2
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
- package/docs/sdk.md +23 -1
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +107 -9
- package/src/cli/agent.ts +177 -0
- package/src/cli/index.ts +8 -1
- package/src/client/canvas/CanvasNode.tsx +8 -4
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
- package/src/client/nodes/ContextNode.tsx +17 -0
- package/src/client/nodes/ExtAppFrame.tsx +40 -3
- package/src/client/nodes/FileNode.tsx +26 -0
- package/src/client/nodes/HtmlNode.tsx +60 -188
- package/src/client/nodes/McpAppNode.tsx +47 -2
- package/src/client/nodes/StatusNode.tsx +20 -0
- package/src/client/nodes/ax-node-actions.ts +39 -0
- package/src/client/nodes/surface-url.ts +48 -0
- package/src/client/state/attention-bridge.ts +5 -0
- package/src/client/state/intent-bridge.ts +33 -0
- package/src/client/theme/global.css +51 -56
- package/src/client/theme/surface-theme.css +142 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/json-render/schema.ts +4 -0
- package/src/json-render/server.ts +13 -0
- package/src/mcp/canvas-access.ts +198 -1
- package/src/mcp/server.ts +232 -2
- package/src/server/ax-context.ts +3 -0
- package/src/server/ax-interaction.ts +549 -0
- package/src/server/ax-state.ts +188 -2
- package/src/server/canvas-db.ts +20 -0
- package/src/server/canvas-operations.ts +11 -0
- package/src/server/canvas-serialization.ts +9 -0
- package/src/server/canvas-state.ts +201 -26
- package/src/server/html-surface.ts +190 -0
- package/src/server/index.ts +122 -7
- package/src/server/mutation-history.ts +5 -0
- package/src/server/placement.ts +5 -1
- package/src/server/server.ts +360 -0
- package/src/shared/surface.ts +38 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PMX-AX node interaction core (plan-004 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* One normalized envelope + capability model for node-originated AX interactions.
|
|
5
|
+
* Eligible nodes emit a validated `PmxAxInteraction`; this module checks the
|
|
6
|
+
* node's capabilities and payload, then maps the interaction onto the EXISTING
|
|
7
|
+
* AX operations (work items, evidence, approvals, review, focus, steering,
|
|
8
|
+
* events). It is host-agnostic and transport-agnostic — the same envelope backs
|
|
9
|
+
* native node events, json-render actions, the sandboxed HTML bridge, MCP apps,
|
|
10
|
+
* and host adapters (later phases).
|
|
11
|
+
*
|
|
12
|
+
* Decoupling: this module never imports the canvas-state singleton at runtime.
|
|
13
|
+
* The dispatcher takes the manager via dependency injection (structural
|
|
14
|
+
* `AxInteractionManager`), so it stays pure and unit-testable and introduces no
|
|
15
|
+
* import cycle (canvas-state → canvas-provenance must not pull this in).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import type { CanvasNodeState } from './canvas-state.js';
|
|
20
|
+
import type { CanvasNodeType } from './canvas-provenance.js';
|
|
21
|
+
import type {
|
|
22
|
+
PmxAxApprovalGate,
|
|
23
|
+
PmxAxElicitation,
|
|
24
|
+
PmxAxEvent,
|
|
25
|
+
PmxAxEventKind,
|
|
26
|
+
PmxAxEvidence,
|
|
27
|
+
PmxAxEvidenceKind,
|
|
28
|
+
PmxAxFocusState,
|
|
29
|
+
PmxAxMode,
|
|
30
|
+
PmxAxModeRequest,
|
|
31
|
+
PmxAxReviewAnchorType,
|
|
32
|
+
PmxAxReviewAnnotation,
|
|
33
|
+
PmxAxReviewKind,
|
|
34
|
+
PmxAxReviewRegion,
|
|
35
|
+
PmxAxReviewSeverity,
|
|
36
|
+
PmxAxSource,
|
|
37
|
+
PmxAxSteeringMessage,
|
|
38
|
+
PmxAxWorkItem,
|
|
39
|
+
PmxAxWorkItemStatus,
|
|
40
|
+
} from './ax-state.js';
|
|
41
|
+
|
|
42
|
+
// ── Interaction types ──────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export const AX_INTERACTION_TYPES = [
|
|
45
|
+
'ax.event.record',
|
|
46
|
+
'ax.steer',
|
|
47
|
+
'ax.work.create',
|
|
48
|
+
'ax.work.update',
|
|
49
|
+
'ax.evidence.add',
|
|
50
|
+
'ax.approval.request',
|
|
51
|
+
'ax.approval.resolve',
|
|
52
|
+
'ax.review.add',
|
|
53
|
+
'ax.focus.set',
|
|
54
|
+
'ax.command.invoke',
|
|
55
|
+
'ax.elicitation.request',
|
|
56
|
+
'ax.mode.request',
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export type AxInteractionType = (typeof AX_INTERACTION_TYPES)[number];
|
|
60
|
+
|
|
61
|
+
// ── Node capability model ──────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export type AxDeliveryMode = 'record-only' | 'notify-agent' | 'send-to-agent';
|
|
64
|
+
|
|
65
|
+
export interface NodeAxCapabilities {
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
/** Interaction types this node may emit. Also the per-node override ceiling. */
|
|
68
|
+
allowed: AxInteractionType[];
|
|
69
|
+
/** Subset of `allowed` that should route through an approval gate (later phases). */
|
|
70
|
+
requiresApproval: AxInteractionType[];
|
|
71
|
+
delivery: AxDeliveryMode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function caps(
|
|
75
|
+
enabled: boolean,
|
|
76
|
+
allowed: AxInteractionType[],
|
|
77
|
+
delivery: AxDeliveryMode = 'record-only',
|
|
78
|
+
): NodeAxCapabilities {
|
|
79
|
+
return { enabled, allowed, requiresApproval: [], delivery };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Server-side default (and per-node ceiling) capabilities per node type, from the
|
|
84
|
+
* plan's node capability matrix. `html`/`html-primitive`, `mcp-app`, and the
|
|
85
|
+
* internal `prompt`/`response` types default to disabled (opt-in / later phases);
|
|
86
|
+
* a node can anchor AX state but only eligible types may EMIT interactions.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabilities> = {
|
|
89
|
+
markdown: caps(true, ['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'], 'notify-agent'),
|
|
90
|
+
context: caps(true, ['ax.focus.set', 'ax.steer', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'], 'notify-agent'),
|
|
91
|
+
status: caps(true, ['ax.work.create', 'ax.work.update', 'ax.approval.request', 'ax.mode.request', 'ax.event.record'], 'notify-agent'),
|
|
92
|
+
file: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
93
|
+
'json-render': caps(true, ['ax.work.create', 'ax.work.update', 'ax.evidence.add', 'ax.elicitation.request', 'ax.event.record']),
|
|
94
|
+
graph: caps(true, ['ax.evidence.add', 'ax.focus.set', 'ax.event.record']),
|
|
95
|
+
ledger: caps(true, ['ax.evidence.add', 'ax.event.record']),
|
|
96
|
+
trace: caps(true, ['ax.evidence.add', 'ax.event.record']),
|
|
97
|
+
image: caps(true, ['ax.evidence.add', 'ax.review.add']),
|
|
98
|
+
webpage: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
99
|
+
group: caps(true, ['ax.focus.set', 'ax.work.create', 'ax.command.invoke', 'ax.event.record']),
|
|
100
|
+
// Opt-in: arbitrary/sandboxed author content. Ceiling is broad but disabled
|
|
101
|
+
// until a node explicitly sets data.axCapabilities.enabled = true.
|
|
102
|
+
html: caps(
|
|
103
|
+
false,
|
|
104
|
+
[
|
|
105
|
+
'ax.work.create',
|
|
106
|
+
'ax.work.update',
|
|
107
|
+
'ax.steer',
|
|
108
|
+
'ax.approval.request',
|
|
109
|
+
'ax.review.add',
|
|
110
|
+
'ax.evidence.add',
|
|
111
|
+
'ax.focus.set',
|
|
112
|
+
'ax.elicitation.request',
|
|
113
|
+
'ax.mode.request',
|
|
114
|
+
'ax.command.invoke',
|
|
115
|
+
'ax.event.record',
|
|
116
|
+
],
|
|
117
|
+
'notify-agent',
|
|
118
|
+
),
|
|
119
|
+
// Opt-in ext-app bridge (Phase 6). Disabled by default; when a node enables it,
|
|
120
|
+
// interactions are still node-scoped (sourceSurface 'mcp-app') and server-validated.
|
|
121
|
+
// Ceiling covers the work-tracking surface a trusted app reasonably drives:
|
|
122
|
+
// record diagnostics + evidence, create/update its own work item, set focus to
|
|
123
|
+
// itself, and request human input. Excludes higher-trust types (steer, approval,
|
|
124
|
+
// review, command, mode) which stay native-control / adapter only.
|
|
125
|
+
'mcp-app': caps(false, [
|
|
126
|
+
'ax.event.record', 'ax.evidence.add', 'ax.work.create', 'ax.work.update',
|
|
127
|
+
'ax.focus.set', 'ax.elicitation.request',
|
|
128
|
+
]),
|
|
129
|
+
// Internal thread nodes — anchor only, no human-facing emission by default.
|
|
130
|
+
prompt: caps(false, ['ax.event.record']),
|
|
131
|
+
response: caps(false, ['ax.event.record']),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const FALLBACK_CAPABILITIES: NodeAxCapabilities = caps(false, []);
|
|
135
|
+
|
|
136
|
+
/** Validate caller-supplied per-node `data.axCapabilities` into a partial override. */
|
|
137
|
+
export function normalizeNodeAxCapabilities(value: unknown): Partial<NodeAxCapabilities> | null {
|
|
138
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
139
|
+
const v = value as Record<string, unknown>;
|
|
140
|
+
const out: Partial<NodeAxCapabilities> = {};
|
|
141
|
+
if (typeof v.enabled === 'boolean') out.enabled = v.enabled;
|
|
142
|
+
if (Array.isArray(v.allowed)) {
|
|
143
|
+
out.allowed = v.allowed.filter((a): a is AxInteractionType =>
|
|
144
|
+
AX_INTERACTION_TYPES.includes(a as AxInteractionType));
|
|
145
|
+
}
|
|
146
|
+
if (Array.isArray(v.requiresApproval)) {
|
|
147
|
+
out.requiresApproval = v.requiresApproval.filter((a): a is AxInteractionType =>
|
|
148
|
+
AX_INTERACTION_TYPES.includes(a as AxInteractionType));
|
|
149
|
+
}
|
|
150
|
+
if (v.delivery === 'record-only' || v.delivery === 'notify-agent' || v.delivery === 'send-to-agent') {
|
|
151
|
+
out.delivery = v.delivery;
|
|
152
|
+
}
|
|
153
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Effective capabilities for a node: the type default merged with the node's own
|
|
158
|
+
* `data.axCapabilities`. A per-node override can toggle `enabled` and NARROW
|
|
159
|
+
* `allowed`, but never grant a type beyond the type's ceiling (security: a
|
|
160
|
+
* pasted/generated node cannot escalate itself).
|
|
161
|
+
*/
|
|
162
|
+
export function resolveNodeAxCapabilities(node: CanvasNodeState): NodeAxCapabilities {
|
|
163
|
+
const base = DEFAULT_NODE_AX_CAPABILITIES[node.type as CanvasNodeType] ?? FALLBACK_CAPABILITIES;
|
|
164
|
+
const override = normalizeNodeAxCapabilities((node.data as Record<string, unknown>).axCapabilities);
|
|
165
|
+
if (!override) return { ...base, allowed: [...base.allowed], requiresApproval: [...base.requiresApproval] };
|
|
166
|
+
const enabled = override.enabled ?? base.enabled;
|
|
167
|
+
const allowed = (override.allowed ?? base.allowed).filter((a) => base.allowed.includes(a));
|
|
168
|
+
const requiresApproval = (override.requiresApproval ?? base.requiresApproval).filter((a) => allowed.includes(a));
|
|
169
|
+
const delivery = override.delivery ?? base.delivery;
|
|
170
|
+
return { enabled, allowed, requiresApproval, delivery };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Envelope + payload validation ──────────────────────────────
|
|
174
|
+
|
|
175
|
+
const EVENT_KINDS = ['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering'] as const;
|
|
176
|
+
const EVIDENCE_KINDS = ['logs', 'tool-result', 'screenshot', 'file', 'diff', 'test-output'] as const;
|
|
177
|
+
const WORK_STATUSES = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
|
178
|
+
const REVIEW_KINDS = ['comment', 'finding'] as const;
|
|
179
|
+
const REVIEW_SEVERITIES = ['info', 'warning', 'error'] as const;
|
|
180
|
+
const REVIEW_ANCHORS = ['node', 'file', 'region'] as const;
|
|
181
|
+
|
|
182
|
+
const InteractionEnvelopeSchema = z.object({
|
|
183
|
+
type: z.enum(AX_INTERACTION_TYPES),
|
|
184
|
+
sourceNodeId: z.string().min(1, 'sourceNodeId is required'),
|
|
185
|
+
sourceSurface: z.enum(['native-node', 'json-render', 'html-node', 'mcp-app', 'adapter']).optional(),
|
|
186
|
+
actor: z
|
|
187
|
+
.object({
|
|
188
|
+
kind: z.enum(['human', 'agent', 'system']),
|
|
189
|
+
id: z.string().optional(),
|
|
190
|
+
displayName: z.string().optional(),
|
|
191
|
+
})
|
|
192
|
+
.optional(),
|
|
193
|
+
payload: z.record(z.string(), z.unknown()).default({}),
|
|
194
|
+
correlationId: z.string().optional(),
|
|
195
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
export type PmxAxInteraction = z.infer<typeof InteractionEnvelopeSchema>;
|
|
199
|
+
|
|
200
|
+
/** Caller-facing interaction input (payload optional; validated on apply). */
|
|
201
|
+
export interface AxInteractionInput {
|
|
202
|
+
type: AxInteractionType;
|
|
203
|
+
sourceNodeId: string;
|
|
204
|
+
sourceSurface?: PmxAxInteraction['sourceSurface'];
|
|
205
|
+
actor?: PmxAxInteraction['actor'];
|
|
206
|
+
payload?: Record<string, unknown>;
|
|
207
|
+
correlationId?: string;
|
|
208
|
+
metadata?: Record<string, unknown>;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
212
|
+
'ax.event.record': z.object({
|
|
213
|
+
kind: z.enum(EVENT_KINDS),
|
|
214
|
+
summary: z.string().min(1),
|
|
215
|
+
detail: z.string().nullish(),
|
|
216
|
+
nodeIds: z.array(z.string()).optional(),
|
|
217
|
+
data: z.record(z.string(), z.unknown()).nullish(),
|
|
218
|
+
}),
|
|
219
|
+
'ax.steer': z.object({ message: z.string().min(1) }),
|
|
220
|
+
'ax.work.create': z.object({
|
|
221
|
+
title: z.string().min(1),
|
|
222
|
+
status: z.enum(WORK_STATUSES).optional(),
|
|
223
|
+
detail: z.string().nullish(),
|
|
224
|
+
nodeIds: z.array(z.string()).optional(),
|
|
225
|
+
}),
|
|
226
|
+
'ax.work.update': z.object({
|
|
227
|
+
id: z.string().min(1),
|
|
228
|
+
title: z.string().optional(),
|
|
229
|
+
status: z.enum(WORK_STATUSES).optional(),
|
|
230
|
+
detail: z.string().nullish(),
|
|
231
|
+
nodeIds: z.array(z.string()).optional(),
|
|
232
|
+
}),
|
|
233
|
+
'ax.evidence.add': z.object({
|
|
234
|
+
kind: z.enum(EVIDENCE_KINDS),
|
|
235
|
+
title: z.string().min(1),
|
|
236
|
+
body: z.string().nullish(),
|
|
237
|
+
ref: z.string().nullish(),
|
|
238
|
+
nodeIds: z.array(z.string()).optional(),
|
|
239
|
+
data: z.record(z.string(), z.unknown()).nullish(),
|
|
240
|
+
}),
|
|
241
|
+
'ax.approval.request': z.object({
|
|
242
|
+
title: z.string().min(1),
|
|
243
|
+
detail: z.string().nullish(),
|
|
244
|
+
action: z.string().nullish(),
|
|
245
|
+
nodeIds: z.array(z.string()).optional(),
|
|
246
|
+
}),
|
|
247
|
+
'ax.approval.resolve': z.object({
|
|
248
|
+
id: z.string().min(1),
|
|
249
|
+
decision: z.enum(['approved', 'rejected']),
|
|
250
|
+
resolution: z.string().optional(),
|
|
251
|
+
}),
|
|
252
|
+
'ax.review.add': z.object({
|
|
253
|
+
body: z.string().min(1),
|
|
254
|
+
kind: z.enum(REVIEW_KINDS).optional(),
|
|
255
|
+
severity: z.enum(REVIEW_SEVERITIES).optional(),
|
|
256
|
+
anchorType: z.enum(REVIEW_ANCHORS).optional(),
|
|
257
|
+
nodeId: z.string().optional(),
|
|
258
|
+
file: z.string().optional(),
|
|
259
|
+
author: z.string().optional(),
|
|
260
|
+
}),
|
|
261
|
+
'ax.focus.set': z.object({ nodeIds: z.array(z.string()).optional() }),
|
|
262
|
+
'ax.elicitation.request': z.object({
|
|
263
|
+
prompt: z.string().min(1),
|
|
264
|
+
fields: z.array(z.string()).optional(),
|
|
265
|
+
nodeIds: z.array(z.string()).optional(),
|
|
266
|
+
}),
|
|
267
|
+
'ax.mode.request': z.object({
|
|
268
|
+
mode: z.enum(['plan', 'execute', 'autonomous']),
|
|
269
|
+
reason: z.string().nullish(),
|
|
270
|
+
nodeIds: z.array(z.string()).optional(),
|
|
271
|
+
}),
|
|
272
|
+
'ax.command.invoke': z.object({
|
|
273
|
+
name: z.string().min(1),
|
|
274
|
+
args: z.record(z.string(), z.unknown()).optional(),
|
|
275
|
+
}),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// ── Dispatch ───────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Structural subset of CanvasStateManager that interaction dispatch needs.
|
|
282
|
+
* Injected so this module stays free of a runtime canvas-state import.
|
|
283
|
+
*/
|
|
284
|
+
export interface AxInteractionManager {
|
|
285
|
+
getNode(id: string): CanvasNodeState | undefined;
|
|
286
|
+
recordAxEvent(
|
|
287
|
+
input: { kind: PmxAxEventKind; summary: string; detail?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
|
|
288
|
+
options?: { source?: PmxAxSource },
|
|
289
|
+
): PmxAxEvent;
|
|
290
|
+
recordSteeringMessage(message: string, options?: { source?: PmxAxSource }): PmxAxSteeringMessage;
|
|
291
|
+
addWorkItem(
|
|
292
|
+
input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
|
|
293
|
+
options?: { source?: PmxAxSource },
|
|
294
|
+
): PmxAxWorkItem;
|
|
295
|
+
updateWorkItem(
|
|
296
|
+
id: string,
|
|
297
|
+
patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
|
|
298
|
+
options?: { source?: PmxAxSource },
|
|
299
|
+
): PmxAxWorkItem | null;
|
|
300
|
+
addEvidence(
|
|
301
|
+
input: { kind: PmxAxEvidenceKind; title: string; body?: string | null; ref?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
|
|
302
|
+
options?: { source?: PmxAxSource },
|
|
303
|
+
): PmxAxEvidence;
|
|
304
|
+
requestApproval(
|
|
305
|
+
input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
|
|
306
|
+
options?: { source?: PmxAxSource },
|
|
307
|
+
): PmxAxApprovalGate;
|
|
308
|
+
resolveApproval(
|
|
309
|
+
id: string,
|
|
310
|
+
decision: 'approved' | 'rejected',
|
|
311
|
+
options?: { resolution?: string; source?: PmxAxSource },
|
|
312
|
+
): PmxAxApprovalGate | null;
|
|
313
|
+
addReviewAnnotation(
|
|
314
|
+
input: {
|
|
315
|
+
body: string;
|
|
316
|
+
kind?: PmxAxReviewKind;
|
|
317
|
+
severity?: PmxAxReviewSeverity;
|
|
318
|
+
anchorType?: PmxAxReviewAnchorType;
|
|
319
|
+
nodeId?: string | null;
|
|
320
|
+
file?: string | null;
|
|
321
|
+
region?: PmxAxReviewRegion | null;
|
|
322
|
+
author?: string | null;
|
|
323
|
+
},
|
|
324
|
+
options?: { source?: PmxAxSource },
|
|
325
|
+
): PmxAxReviewAnnotation | null;
|
|
326
|
+
setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState;
|
|
327
|
+
requestElicitation(
|
|
328
|
+
input: { prompt: string; fields?: string[]; nodeIds?: string[] },
|
|
329
|
+
options?: { source?: PmxAxSource },
|
|
330
|
+
): PmxAxElicitation;
|
|
331
|
+
requestMode(
|
|
332
|
+
input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
|
|
333
|
+
options?: { source?: PmxAxSource },
|
|
334
|
+
): PmxAxModeRequest;
|
|
335
|
+
invokeCommand(name: string, args?: Record<string, unknown> | null, options?: { source?: PmxAxSource }): PmxAxEvent | null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export interface AxInteractionEvent {
|
|
339
|
+
event: string;
|
|
340
|
+
payload: Record<string, unknown>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export type AxInteractionPublicResult =
|
|
344
|
+
| { ok: true; type: AxInteractionType; sourceNodeId: string; primitive: unknown }
|
|
345
|
+
| { ok: false; status: number; code: string; error: string };
|
|
346
|
+
|
|
347
|
+
export interface AxInteractionResult {
|
|
348
|
+
result: AxInteractionPublicResult;
|
|
349
|
+
events: AxInteractionEvent[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function outcomeEvent(extra: Record<string, unknown>): AxInteractionEvent {
|
|
353
|
+
return { event: 'ax-interaction', payload: extra };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function reject(
|
|
357
|
+
type: string,
|
|
358
|
+
sourceNodeId: string,
|
|
359
|
+
status: number,
|
|
360
|
+
code: string,
|
|
361
|
+
error: string,
|
|
362
|
+
): AxInteractionResult {
|
|
363
|
+
return {
|
|
364
|
+
result: { ok: false, status, code, error },
|
|
365
|
+
events: [outcomeEvent({ ok: false, type, sourceNodeId, code, error })],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function accept(
|
|
370
|
+
type: AxInteractionType,
|
|
371
|
+
sourceNodeId: string,
|
|
372
|
+
primitive: unknown,
|
|
373
|
+
stateEvent: string,
|
|
374
|
+
statePayload: Record<string, unknown>,
|
|
375
|
+
): AxInteractionResult {
|
|
376
|
+
return {
|
|
377
|
+
result: { ok: true, type, sourceNodeId, primitive },
|
|
378
|
+
events: [
|
|
379
|
+
outcomeEvent({ ok: true, type, sourceNodeId }),
|
|
380
|
+
{ event: stateEvent, payload: statePayload },
|
|
381
|
+
],
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Validate + execute a node-originated AX interaction. Returns the public result
|
|
387
|
+
* plus the SSE events the caller should emit (accepted/rejected outcome + the
|
|
388
|
+
* underlying AX state event). Never throws on bad input — returns an `ok: false`
|
|
389
|
+
* result with an appropriate HTTP-ish status.
|
|
390
|
+
*/
|
|
391
|
+
export function applyAxInteraction(
|
|
392
|
+
manager: AxInteractionManager,
|
|
393
|
+
rawBody: unknown,
|
|
394
|
+
source: PmxAxSource,
|
|
395
|
+
): AxInteractionResult {
|
|
396
|
+
const parsed = InteractionEnvelopeSchema.safeParse(rawBody);
|
|
397
|
+
if (!parsed.success) {
|
|
398
|
+
const error = parsed.error.issues.map((i) => `${i.path.join('.') || 'envelope'}: ${i.message}`).join('; ');
|
|
399
|
+
const type = typeof (rawBody as { type?: unknown })?.type === 'string' ? String((rawBody as { type?: unknown }).type) : 'unknown';
|
|
400
|
+
const sourceNodeId = typeof (rawBody as { sourceNodeId?: unknown })?.sourceNodeId === 'string'
|
|
401
|
+
? String((rawBody as { sourceNodeId?: unknown }).sourceNodeId)
|
|
402
|
+
: '';
|
|
403
|
+
return reject(type, sourceNodeId, 400, 'invalid-envelope', error);
|
|
404
|
+
}
|
|
405
|
+
const interaction = parsed.data;
|
|
406
|
+
const { type, sourceNodeId, payload } = interaction;
|
|
407
|
+
|
|
408
|
+
const node = manager.getNode(sourceNodeId);
|
|
409
|
+
if (!node) return reject(type, sourceNodeId, 404, 'unknown-node', `Source node "${sourceNodeId}" not found.`);
|
|
410
|
+
|
|
411
|
+
const capabilities = resolveNodeAxCapabilities(node);
|
|
412
|
+
if (!capabilities.enabled) {
|
|
413
|
+
return reject(type, sourceNodeId, 403, 'ax-disabled', `AX interactions are not enabled for node "${sourceNodeId}".`);
|
|
414
|
+
}
|
|
415
|
+
if (!capabilities.allowed.includes(type)) {
|
|
416
|
+
return reject(type, sourceNodeId, 403, 'not-allowed', `Node type "${node.type}" cannot emit "${type}".`);
|
|
417
|
+
}
|
|
418
|
+
// Fail closed: approval-gated interaction types are rejected until approval
|
|
419
|
+
// routing lands, rather than dispatched without the gate they require.
|
|
420
|
+
if (capabilities.requiresApproval.includes(type)) {
|
|
421
|
+
return reject(type, sourceNodeId, 403, 'requires-approval', `"${type}" requires approval routing, which is not yet available.`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const schema = PAYLOAD_SCHEMAS[type];
|
|
425
|
+
const payloadParsed = schema.safeParse(payload);
|
|
426
|
+
if (!payloadParsed.success) {
|
|
427
|
+
const error = payloadParsed.error.issues.map((i) => `${i.path.join('.') || 'payload'}: ${i.message}`).join('; ');
|
|
428
|
+
return reject(type, sourceNodeId, 400, 'invalid-payload', error);
|
|
429
|
+
}
|
|
430
|
+
const opts = { source };
|
|
431
|
+
|
|
432
|
+
// Sandboxed/semi-trusted surfaces — sandboxed HTML, MCP apps, and the
|
|
433
|
+
// json-render/graph viewer (all opaque-origin iframes rendering author-controlled
|
|
434
|
+
// content) — may only emit interactions scoped to their OWN node: caller-supplied
|
|
435
|
+
// nodeIds are clamped to the source node so a spec/app cannot anchor AX state on
|
|
436
|
+
// arbitrary canvas nodes. Trusted surfaces (native node controls, host adapters)
|
|
437
|
+
// may pass explicit nodeIds. Note: the clamp covers node *re-association*; target
|
|
438
|
+
// ids for ax.work.update / ax.approval.resolve remain addressable across surfaces
|
|
439
|
+
// (ids are non-secret — surfaced in canvas://ax-work), which is accepted under the
|
|
440
|
+
// single-workspace local-trust model.
|
|
441
|
+
const scoped =
|
|
442
|
+
interaction.sourceSurface === 'html-node' ||
|
|
443
|
+
interaction.sourceSurface === 'mcp-app' ||
|
|
444
|
+
interaction.sourceSurface === 'json-render';
|
|
445
|
+
const scopedNodeIds = (requested?: string[]): string[] => (scoped ? [sourceNodeId] : (requested ?? [sourceNodeId]));
|
|
446
|
+
|
|
447
|
+
switch (type) {
|
|
448
|
+
case 'ax.event.record': {
|
|
449
|
+
const p = payloadParsed.data as { kind: PmxAxEventKind; summary: string; detail?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null };
|
|
450
|
+
const event = manager.recordAxEvent(
|
|
451
|
+
{ kind: p.kind, summary: p.summary, detail: p.detail ?? null, nodeIds: scopedNodeIds(p.nodeIds), data: p.data ?? null },
|
|
452
|
+
opts,
|
|
453
|
+
);
|
|
454
|
+
return accept(type, sourceNodeId, event, 'ax-event-created', { event });
|
|
455
|
+
}
|
|
456
|
+
case 'ax.steer': {
|
|
457
|
+
const p = payloadParsed.data as { message: string };
|
|
458
|
+
const steering = manager.recordSteeringMessage(p.message, opts);
|
|
459
|
+
return accept(type, sourceNodeId, steering, 'ax-event-created', { steering });
|
|
460
|
+
}
|
|
461
|
+
case 'ax.work.create': {
|
|
462
|
+
const p = payloadParsed.data as { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] };
|
|
463
|
+
const workItem = manager.addWorkItem(
|
|
464
|
+
{ title: p.title, ...(p.status ? { status: p.status } : {}), ...(p.detail !== undefined ? { detail: p.detail } : {}), nodeIds: scopedNodeIds(p.nodeIds) },
|
|
465
|
+
opts,
|
|
466
|
+
);
|
|
467
|
+
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem });
|
|
468
|
+
}
|
|
469
|
+
case 'ax.work.update': {
|
|
470
|
+
const p = payloadParsed.data as { id: string; title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] };
|
|
471
|
+
const { id, ...patch } = p;
|
|
472
|
+
if (scoped && patch.nodeIds !== undefined) patch.nodeIds = [sourceNodeId];
|
|
473
|
+
const workItem = manager.updateWorkItem(id, patch, opts);
|
|
474
|
+
if (!workItem) return reject(type, sourceNodeId, 404, 'work-item-not-found', `Work item "${id}" not found.`);
|
|
475
|
+
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem });
|
|
476
|
+
}
|
|
477
|
+
case 'ax.evidence.add': {
|
|
478
|
+
const p = payloadParsed.data as { kind: PmxAxEvidenceKind; title: string; body?: string | null; ref?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null };
|
|
479
|
+
const evidence = manager.addEvidence(
|
|
480
|
+
{ kind: p.kind, title: p.title, body: p.body ?? null, ref: p.ref ?? null, nodeIds: scopedNodeIds(p.nodeIds), data: p.data ?? null },
|
|
481
|
+
opts,
|
|
482
|
+
);
|
|
483
|
+
return accept(type, sourceNodeId, evidence, 'ax-event-created', { evidence });
|
|
484
|
+
}
|
|
485
|
+
case 'ax.approval.request': {
|
|
486
|
+
const p = payloadParsed.data as { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] };
|
|
487
|
+
const approvalGate = manager.requestApproval(
|
|
488
|
+
{ title: p.title, ...(p.detail !== undefined ? { detail: p.detail } : {}), ...(p.action !== undefined ? { action: p.action } : {}), nodeIds: scopedNodeIds(p.nodeIds) },
|
|
489
|
+
opts,
|
|
490
|
+
);
|
|
491
|
+
return accept(type, sourceNodeId, approvalGate, 'ax-state-changed', { approvalGate });
|
|
492
|
+
}
|
|
493
|
+
case 'ax.approval.resolve': {
|
|
494
|
+
const p = payloadParsed.data as { id: string; decision: 'approved' | 'rejected'; resolution?: string };
|
|
495
|
+
const approvalGate = manager.resolveApproval(p.id, p.decision, { ...(p.resolution !== undefined ? { resolution: p.resolution } : {}), source });
|
|
496
|
+
if (!approvalGate) return reject(type, sourceNodeId, 404, 'approval-not-found', `Approval "${p.id}" not found or already resolved.`);
|
|
497
|
+
return accept(type, sourceNodeId, approvalGate, 'ax-state-changed', { approvalGate });
|
|
498
|
+
}
|
|
499
|
+
case 'ax.review.add': {
|
|
500
|
+
const p = payloadParsed.data as { body: string; kind?: PmxAxReviewKind; severity?: PmxAxReviewSeverity; anchorType?: PmxAxReviewAnchorType; nodeId?: string; file?: string; author?: string };
|
|
501
|
+
// Sandboxed surfaces may only review their own node; trusted surfaces may
|
|
502
|
+
// anchor to a file/region or another node.
|
|
503
|
+
const anchorType: PmxAxReviewAnchorType = scoped ? 'node' : (p.anchorType ?? 'node');
|
|
504
|
+
const reviewAnnotation = manager.addReviewAnnotation(
|
|
505
|
+
{
|
|
506
|
+
body: p.body,
|
|
507
|
+
...(p.kind ? { kind: p.kind } : {}),
|
|
508
|
+
...(p.severity ? { severity: p.severity } : {}),
|
|
509
|
+
anchorType,
|
|
510
|
+
nodeId: scoped ? sourceNodeId : (anchorType === 'node' ? (p.nodeId ?? sourceNodeId) : (p.nodeId ?? null)),
|
|
511
|
+
...(!scoped && p.file !== undefined ? { file: p.file } : {}),
|
|
512
|
+
...(p.author !== undefined ? { author: p.author } : {}),
|
|
513
|
+
},
|
|
514
|
+
opts,
|
|
515
|
+
);
|
|
516
|
+
if (!reviewAnnotation) return reject(type, sourceNodeId, 400, 'invalid-review-anchor', 'Node-anchored review requires a nodeId that exists on the canvas.');
|
|
517
|
+
return accept(type, sourceNodeId, reviewAnnotation, 'ax-state-changed', { reviewAnnotation });
|
|
518
|
+
}
|
|
519
|
+
case 'ax.focus.set': {
|
|
520
|
+
const p = payloadParsed.data as { nodeIds?: string[] };
|
|
521
|
+
const focus = manager.setAxFocus(scopedNodeIds(p.nodeIds), opts);
|
|
522
|
+
return accept(type, sourceNodeId, focus, 'ax-state-changed', { focus });
|
|
523
|
+
}
|
|
524
|
+
case 'ax.elicitation.request': {
|
|
525
|
+
const p = payloadParsed.data as { prompt: string; fields?: string[]; nodeIds?: string[] };
|
|
526
|
+
const elicitation = manager.requestElicitation(
|
|
527
|
+
{ prompt: p.prompt, ...(p.fields ? { fields: p.fields } : {}), nodeIds: scopedNodeIds(p.nodeIds) },
|
|
528
|
+
opts,
|
|
529
|
+
);
|
|
530
|
+
return accept(type, sourceNodeId, elicitation, 'ax-state-changed', { elicitation });
|
|
531
|
+
}
|
|
532
|
+
case 'ax.mode.request': {
|
|
533
|
+
const p = payloadParsed.data as { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] };
|
|
534
|
+
const modeRequest = manager.requestMode(
|
|
535
|
+
{ mode: p.mode, ...(p.reason !== undefined ? { reason: p.reason } : {}), nodeIds: scopedNodeIds(p.nodeIds) },
|
|
536
|
+
opts,
|
|
537
|
+
);
|
|
538
|
+
return accept(type, sourceNodeId, modeRequest, 'ax-state-changed', { modeRequest });
|
|
539
|
+
}
|
|
540
|
+
case 'ax.command.invoke': {
|
|
541
|
+
const p = payloadParsed.data as { name: string; args?: Record<string, unknown> };
|
|
542
|
+
const event = manager.invokeCommand(p.name, p.args ?? null, opts);
|
|
543
|
+
if (!event) return reject(type, sourceNodeId, 400, 'unknown-command', `Unknown command "${p.name}".`);
|
|
544
|
+
return accept(type, sourceNodeId, event, 'ax-event-created', { event });
|
|
545
|
+
}
|
|
546
|
+
default:
|
|
547
|
+
return reject(type, sourceNodeId, 501, 'not-executable', `"${type}" is recognized but not yet executable.`);
|
|
548
|
+
}
|
|
549
|
+
}
|