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.
- package/CHANGELOG.md +65 -0
- package/Readme.md +14 -2
- package/dist/canvas/index.js +82 -41
- package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +20 -1
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +28 -0
- package/dist/types/server/ax-wait.d.ts +23 -0
- package/dist/types/server/canvas-state.d.ts +55 -3
- package/dist/types/server/html-surface.d.ts +7 -0
- package/dist/types/server/index.d.ts +60 -2
- package/docs/ax-host-adapter-contract.md +65 -0
- package/docs/http-api.md +34 -2
- package/docs/mcp.md +5 -1
- package/docs/screenshot.png +0 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +50 -8
- package/skills/pmx-canvas/references/codex-app-adapter.md +15 -1
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +31 -0
- package/src/client/nodes/ExtAppFrame.tsx +73 -5
- package/src/client/nodes/HtmlNode.tsx +12 -3
- package/src/client/nodes/McpAppNode.tsx +12 -3
- package/src/json-render/renderer/index.tsx +3 -0
- package/src/mcp/canvas-access.ts +74 -5
- package/src/mcp/server.ts +94 -53
- package/src/server/ax-context.ts +7 -1
- package/src/server/ax-state.ts +87 -0
- package/src/server/ax-wait.ts +56 -0
- package/src/server/canvas-state.ts +131 -3
- package/src/server/html-surface.ts +49 -11
- package/src/server/index.ts +82 -2
- package/src/server/server.ts +223 -11
package/src/server/ax-state.ts
CHANGED
|
@@ -157,6 +157,22 @@ export interface PmxAxFocusContext extends PmxAxFocusState {
|
|
|
157
157
|
nodes: AgentContextNode[];
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// ── Pending agent-actionable digest (open canvas-bound items) ──────
|
|
161
|
+
export interface PendingAxActivityItem {
|
|
162
|
+
kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
|
|
163
|
+
id: string;
|
|
164
|
+
title: string;
|
|
165
|
+
status: string;
|
|
166
|
+
nodeIds: string[];
|
|
167
|
+
source: PmxAxSource | null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Delivery lead block (compact, un-truncated; for per-turn injection) ──
|
|
171
|
+
export interface PmxAxDeliveryContext {
|
|
172
|
+
pendingSteering: PmxAxSteeringMessage[];
|
|
173
|
+
pendingActivity: PendingAxActivityItem[];
|
|
174
|
+
}
|
|
175
|
+
|
|
160
176
|
export interface PmxAxContext {
|
|
161
177
|
version: 1;
|
|
162
178
|
generatedAt: string;
|
|
@@ -164,6 +180,10 @@ export interface PmxAxContext {
|
|
|
164
180
|
nodeCount: number;
|
|
165
181
|
edgeCount: number;
|
|
166
182
|
};
|
|
183
|
+
// Compact, loop-safe lead block of what's awaiting the agent right now
|
|
184
|
+
// (undelivered steering + open work/approvals/elicitations/mode requests).
|
|
185
|
+
// Sits ABOVE the full dump so a host adapter can inject it un-truncated.
|
|
186
|
+
delivery: PmxAxDeliveryContext;
|
|
167
187
|
pinned: PmxAxPinnedContext;
|
|
168
188
|
focus: PmxAxFocusContext;
|
|
169
189
|
workItems: PmxAxWorkItem[];
|
|
@@ -176,6 +196,44 @@ export interface PmxAxContext {
|
|
|
176
196
|
host: PmxAxHostCapability | null;
|
|
177
197
|
}
|
|
178
198
|
|
|
199
|
+
const OPEN_AX_WORK_STATUSES = new Set<PmxAxWorkItemStatus>(['todo', 'in-progress', 'blocked']);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Open, agent-actionable canvas-bound AX items (open work items + pending approval
|
|
203
|
+
* gates / elicitations / mode requests). Unlike steering (a directive routed through
|
|
204
|
+
* the claim/ack delivery queue), these are STATE the human curates in the browser —
|
|
205
|
+
* they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
|
|
206
|
+
* but an adapterless client that only POLLS the delivery surface never saw them.
|
|
207
|
+
* Optionally excludes items the consumer itself originated (loop prevention),
|
|
208
|
+
* mirroring getPendingSteering. Shared by the MCP delivery surface and the HTTP
|
|
209
|
+
* context lead block so the digest never drifts between the two.
|
|
210
|
+
*/
|
|
211
|
+
export function buildPendingAxActivity(state: PmxAxState, consumer?: string): PendingAxActivityItem[] {
|
|
212
|
+
const notMine = (source: PmxAxSource | null) => !consumer || source !== consumer;
|
|
213
|
+
const out: PendingAxActivityItem[] = [];
|
|
214
|
+
for (const w of state.workItems) {
|
|
215
|
+
if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
|
|
216
|
+
out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds, source: w.source });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const g of state.approvalGates) {
|
|
220
|
+
if (g.status === 'pending' && notMine(g.source)) {
|
|
221
|
+
out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds, source: g.source });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const e of state.elicitations) {
|
|
225
|
+
if (e.status === 'pending' && notMine(e.source)) {
|
|
226
|
+
out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds, source: e.source });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const m of state.modeRequests) {
|
|
230
|
+
if (m.status === 'pending' && notMine(m.source)) {
|
|
231
|
+
out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds, source: m.source });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
179
237
|
const AX_SOURCES = new Set<PmxAxSource>(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']);
|
|
180
238
|
|
|
181
239
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -207,6 +265,33 @@ function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[]
|
|
|
207
265
|
|
|
208
266
|
const AX_EVENT_KINDS = new Set<PmxAxEventKind>(['prompt', 'assistant-message', 'tool-start', 'tool-result', 'failure', 'approval', 'steering', 'command']);
|
|
209
267
|
|
|
268
|
+
// ── Activity ingestion (harness-forwarded tool/session events) ─────
|
|
269
|
+
// A normalized activity the agent's harness forwards; the board auto-reacts.
|
|
270
|
+
// The precise activity kind is preserved on the recorded event's `data.activityKind`;
|
|
271
|
+
// only the (coarser) timeline event kind is constrained to PmxAxEventKind.
|
|
272
|
+
export type PmxAxActivityKind =
|
|
273
|
+
| 'tool-start' | 'tool-result' | 'failure' | 'error'
|
|
274
|
+
| 'session-start' | 'session-end' | 'command' | 'note';
|
|
275
|
+
const AX_ACTIVITY_KINDS = new Set<PmxAxActivityKind>([
|
|
276
|
+
'tool-start', 'tool-result', 'failure', 'error', 'session-start', 'session-end', 'command', 'note',
|
|
277
|
+
]);
|
|
278
|
+
const ACTIVITY_TO_EVENT_KIND: Record<PmxAxActivityKind, PmxAxEventKind> = {
|
|
279
|
+
'tool-start': 'tool-start',
|
|
280
|
+
'tool-result': 'tool-result',
|
|
281
|
+
'failure': 'failure',
|
|
282
|
+
'error': 'failure',
|
|
283
|
+
'session-start': 'assistant-message',
|
|
284
|
+
'session-end': 'assistant-message',
|
|
285
|
+
'command': 'command',
|
|
286
|
+
'note': 'assistant-message',
|
|
287
|
+
};
|
|
288
|
+
export function isAxActivityKind(value: unknown): value is PmxAxActivityKind {
|
|
289
|
+
return typeof value === 'string' && AX_ACTIVITY_KINDS.has(value as PmxAxActivityKind);
|
|
290
|
+
}
|
|
291
|
+
export function mapAxActivityKindToEventKind(kind: PmxAxActivityKind): PmxAxEventKind {
|
|
292
|
+
return ACTIVITY_TO_EVENT_KIND[kind];
|
|
293
|
+
}
|
|
294
|
+
|
|
210
295
|
// ── Command registry (plan-004 Phase 5) ────────────────────────
|
|
211
296
|
// Named, registry-gated PMX intents — NOT arbitrary execution. Invoking a command
|
|
212
297
|
// records a timeline event a host/agent can observe and act on; unknown names are
|
|
@@ -725,6 +810,7 @@ export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): Pm
|
|
|
725
810
|
|
|
726
811
|
export function buildAxContext(input: {
|
|
727
812
|
layout: CanvasLayout;
|
|
813
|
+
delivery: PmxAxDeliveryContext;
|
|
728
814
|
pinned: PmxAxPinnedContext;
|
|
729
815
|
focus: PmxAxFocusState;
|
|
730
816
|
focusNodes: AgentContextNode[];
|
|
@@ -744,6 +830,7 @@ export function buildAxContext(input: {
|
|
|
744
830
|
nodeCount: input.layout.nodes.length,
|
|
745
831
|
edgeCount: input.layout.edges.length,
|
|
746
832
|
},
|
|
833
|
+
delivery: input.delivery,
|
|
747
834
|
pinned: input.pinned,
|
|
748
835
|
focus: {
|
|
749
836
|
...input.focus,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { canvasState } from './canvas-state.js';
|
|
2
|
+
|
|
3
|
+
/** Hard ceiling on a single blocking wait, regardless of the requested timeout. */
|
|
4
|
+
export const AX_WAIT_MAX_MS = 120000;
|
|
5
|
+
|
|
6
|
+
export interface AxWaitResult<T> {
|
|
7
|
+
/** Latest value, or null if the item does not exist / vanished mid-wait. */
|
|
8
|
+
value: T | null;
|
|
9
|
+
/** True only when the item still exists and is still pending after the wait. */
|
|
10
|
+
pending: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Block until a canvas-bound AX item resolves (its status leaves `pending`), the
|
|
15
|
+
* timeout elapses, or the request aborts — the server side of report primitive D
|
|
16
|
+
* ("gates that actually gate"). Resolves immediately when the item is already
|
|
17
|
+
* resolved, missing, or `timeoutMs <= 0` (a plain single read). Subscribes to the
|
|
18
|
+
* `ax` change channel and always disposes the listener + timer.
|
|
19
|
+
*/
|
|
20
|
+
export async function waitForAxResolution<T extends { status: string }>(opts: {
|
|
21
|
+
read: () => T | null;
|
|
22
|
+
isResolved: (value: T) => boolean;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<AxWaitResult<T>> {
|
|
26
|
+
const { read, isResolved } = opts;
|
|
27
|
+
const timeoutMs = Math.max(0, Math.min(opts.timeoutMs, AX_WAIT_MAX_MS));
|
|
28
|
+
const pendingOf = (v: T | null): boolean => (v ? !isResolved(v) : false);
|
|
29
|
+
|
|
30
|
+
const current = read();
|
|
31
|
+
if (!current || isResolved(current) || timeoutMs === 0 || opts.signal?.aborted) {
|
|
32
|
+
return { value: current, pending: pendingOf(current) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return new Promise<AxWaitResult<T>>((resolve) => {
|
|
36
|
+
let done = false;
|
|
37
|
+
const finish = (): void => {
|
|
38
|
+
if (done) return;
|
|
39
|
+
done = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
dispose();
|
|
42
|
+
opts.signal?.removeEventListener('abort', onSettle);
|
|
43
|
+
const v = read();
|
|
44
|
+
resolve({ value: v, pending: pendingOf(v) });
|
|
45
|
+
};
|
|
46
|
+
const onSettle = finish;
|
|
47
|
+
const check = (type: string): void => {
|
|
48
|
+
if (type !== 'ax') return;
|
|
49
|
+
const v = read();
|
|
50
|
+
if (!v || isResolved(v)) finish();
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
53
|
+
const dispose = canvasState.onChange(check);
|
|
54
|
+
opts.signal?.addEventListener('abort', onSettle, { once: true });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -70,6 +70,8 @@ import {
|
|
|
70
70
|
listAxCommands,
|
|
71
71
|
AX_COMMAND_REGISTRY,
|
|
72
72
|
normalizeAxPolicy,
|
|
73
|
+
mapAxActivityKindToEventKind,
|
|
74
|
+
type PmxAxActivityKind,
|
|
73
75
|
type PmxAxElicitation,
|
|
74
76
|
type PmxAxModeRequest,
|
|
75
77
|
type PmxAxMode,
|
|
@@ -334,13 +336,25 @@ class CanvasStateManager {
|
|
|
334
336
|
// ── Change listeners (for MCP resource notifications) ──────
|
|
335
337
|
private _changeListeners: ((type: CanvasChangeType) => void)[] = [];
|
|
336
338
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Register a listener for state changes. Used by MCP server to emit resource
|
|
341
|
+
* notifications and by the blocking-wait endpoints to await an AX transition.
|
|
342
|
+
* Returns a disposer that unregisters the listener (callers that don't need it
|
|
343
|
+
* — e.g. the long-lived MCP subscription — may ignore the return value).
|
|
344
|
+
*/
|
|
345
|
+
onChange(cb: (type: CanvasChangeType) => void): () => void {
|
|
339
346
|
this._changeListeners.push(cb);
|
|
347
|
+
return () => {
|
|
348
|
+
const i = this._changeListeners.indexOf(cb);
|
|
349
|
+
if (i >= 0) this._changeListeners.splice(i, 1);
|
|
350
|
+
};
|
|
340
351
|
}
|
|
341
352
|
|
|
342
353
|
private notifyChange(type: CanvasChangeType): void {
|
|
343
|
-
|
|
354
|
+
// Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
|
|
355
|
+
// itself synchronously here, and splicing the live array mid-iteration would skip
|
|
356
|
+
// the next listener for this notification.
|
|
357
|
+
for (const cb of [...this._changeListeners]) {
|
|
344
358
|
try {
|
|
345
359
|
cb(type);
|
|
346
360
|
} catch (error) {
|
|
@@ -2049,6 +2063,19 @@ class CanvasStateManager {
|
|
|
2049
2063
|
return applied.modeRequests.find((m) => m.id === id) ?? null;
|
|
2050
2064
|
}
|
|
2051
2065
|
|
|
2066
|
+
// ── Single-item AX readers (canvas-bound; for the blocking-wait endpoints) ──
|
|
2067
|
+
getApproval(id: string): PmxAxApprovalGate | null {
|
|
2068
|
+
return this.getAxState().approvalGates.find((g) => g.id === id) ?? null;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
getElicitation(id: string): PmxAxElicitation | null {
|
|
2072
|
+
return this.getAxState().elicitations.find((e) => e.id === id) ?? null;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
getModeRequest(id: string): PmxAxModeRequest | null {
|
|
2076
|
+
return this.getAxState().modeRequests.find((m) => m.id === id) ?? null;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2052
2079
|
getCommandRegistry(): PmxAxCommandDescriptor[] {
|
|
2053
2080
|
return listAxCommands();
|
|
2054
2081
|
}
|
|
@@ -2171,6 +2198,107 @@ class CanvasStateManager {
|
|
|
2171
2198
|
}
|
|
2172
2199
|
}
|
|
2173
2200
|
|
|
2201
|
+
/**
|
|
2202
|
+
* Ingest a normalized agent activity (a tool/session event a harness forwards)
|
|
2203
|
+
* and apply kind-driven board reactions, so the agent's real work flows back into
|
|
2204
|
+
* the board without it remembering to push each item (report primitive A — makes
|
|
2205
|
+
* AX bidirectional). Always records a timeline event; then, unless the caller
|
|
2206
|
+
* overrides/suppresses via `reactions`, applies defaults by kind/outcome:
|
|
2207
|
+
* • failure | error | outcome==='failure' → work item (blocked) + review
|
|
2208
|
+
* (finding/error, anchored to a valid nodeId else the `ref` file) + evidence (logs)
|
|
2209
|
+
* • tool-result + outcome==='success' → evidence (tool-result)
|
|
2210
|
+
* • everything else (tool-start, session-*, command, note) → event only
|
|
2211
|
+
* A reaction value of `false` suppresses it; an object overrides its fields/forces it on.
|
|
2212
|
+
*/
|
|
2213
|
+
ingestActivity(
|
|
2214
|
+
input: {
|
|
2215
|
+
kind: PmxAxActivityKind;
|
|
2216
|
+
title: string;
|
|
2217
|
+
summary?: string | null;
|
|
2218
|
+
outcome?: 'success' | 'failure';
|
|
2219
|
+
ref?: string | null;
|
|
2220
|
+
nodeIds?: string[];
|
|
2221
|
+
data?: Record<string, unknown> | null;
|
|
2222
|
+
reactions?: {
|
|
2223
|
+
workItem?: false | { status?: PmxAxWorkItemStatus; detail?: string | null };
|
|
2224
|
+
evidence?: false | { kind?: PmxAxEvidenceKind; body?: string | null };
|
|
2225
|
+
review?: false | { severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind; anchorType?: PmxAxReviewAnchorType; nodeId?: string | null };
|
|
2226
|
+
};
|
|
2227
|
+
},
|
|
2228
|
+
options: { source?: PmxAxSource } = {},
|
|
2229
|
+
): { event: PmxAxEvent; workItem: PmxAxWorkItem | null; evidence: PmxAxEvidence | null; review: PmxAxReviewAnnotation | null } {
|
|
2230
|
+
const source = options.source ?? 'api';
|
|
2231
|
+
const summary = input.summary ?? input.title;
|
|
2232
|
+
const isFailure = input.kind === 'failure' || input.kind === 'error' || input.outcome === 'failure';
|
|
2233
|
+
const isToolSuccess = input.kind === 'tool-result' && input.outcome === 'success';
|
|
2234
|
+
const nodeIds = input.nodeIds ?? [];
|
|
2235
|
+
const anchorNodeId = nodeIds.find((n) => this.nodes.has(n)) ?? null;
|
|
2236
|
+
|
|
2237
|
+
// (1) Always record the activity on the timeline (precise kind on data.activityKind).
|
|
2238
|
+
const event = this.recordAxEvent(
|
|
2239
|
+
{
|
|
2240
|
+
kind: mapAxActivityKindToEventKind(input.kind),
|
|
2241
|
+
summary: input.title,
|
|
2242
|
+
detail: input.summary ?? null,
|
|
2243
|
+
nodeIds,
|
|
2244
|
+
// Caller data first so the canonical fields always win — a malformed/hostile
|
|
2245
|
+
// payload can't overwrite activityKind/outcome/ref (which the docstring +
|
|
2246
|
+
// reaction logic treat as authoritative).
|
|
2247
|
+
data: {
|
|
2248
|
+
...(input.data ?? {}),
|
|
2249
|
+
activityKind: input.kind,
|
|
2250
|
+
...(input.outcome ? { outcome: input.outcome } : {}),
|
|
2251
|
+
...(input.ref ? { ref: input.ref } : {}),
|
|
2252
|
+
},
|
|
2253
|
+
},
|
|
2254
|
+
{ source },
|
|
2255
|
+
);
|
|
2256
|
+
|
|
2257
|
+
// (2) Resolve reactions: kind-driven defaults, overridable per call.
|
|
2258
|
+
const r = input.reactions ?? {};
|
|
2259
|
+
const wantWorkItem = r.workItem === false ? null : (r.workItem ?? (isFailure ? {} : null));
|
|
2260
|
+
const wantEvidence = r.evidence === false
|
|
2261
|
+
? null
|
|
2262
|
+
: (r.evidence ?? (isFailure ? { kind: 'logs' as PmxAxEvidenceKind } : isToolSuccess ? { kind: 'tool-result' as PmxAxEvidenceKind } : null));
|
|
2263
|
+
const wantReview = r.review === false ? null : (r.review ?? (isFailure ? {} : null));
|
|
2264
|
+
|
|
2265
|
+
let workItem: PmxAxWorkItem | null = null;
|
|
2266
|
+
if (wantWorkItem) {
|
|
2267
|
+
workItem = this.addWorkItem(
|
|
2268
|
+
{ title: input.title, status: wantWorkItem.status ?? 'blocked', detail: wantWorkItem.detail ?? summary, nodeIds },
|
|
2269
|
+
{ source },
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
let evidence: PmxAxEvidence | null = null;
|
|
2274
|
+
if (wantEvidence) {
|
|
2275
|
+
evidence = this.addEvidence(
|
|
2276
|
+
{ kind: wantEvidence.kind ?? 'logs', title: input.title, body: wantEvidence.body ?? input.summary ?? null, ref: input.ref ?? null, nodeIds },
|
|
2277
|
+
{ source },
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
let review: PmxAxReviewAnnotation | null = null;
|
|
2282
|
+
if (wantReview) {
|
|
2283
|
+
const reviewNodeId = wantReview.nodeId ?? anchorNodeId;
|
|
2284
|
+
// addReviewAnnotation returns null on a bad node anchor — that just skips the
|
|
2285
|
+
// review; it never fails the whole ingest (the event + other reactions stand).
|
|
2286
|
+
review = this.addReviewAnnotation(
|
|
2287
|
+
{
|
|
2288
|
+
body: summary,
|
|
2289
|
+
kind: wantReview.kind ?? 'finding',
|
|
2290
|
+
severity: wantReview.severity ?? 'error',
|
|
2291
|
+
...(wantReview.anchorType ? { anchorType: wantReview.anchorType } : {}),
|
|
2292
|
+
...(reviewNodeId ? { nodeId: reviewNodeId } : {}),
|
|
2293
|
+
...(input.ref ? { file: input.ref } : {}),
|
|
2294
|
+
},
|
|
2295
|
+
{ source },
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
return { event, workItem, evidence, review };
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2174
2302
|
getAxEvents(q: AxTimelineQuery = {}): PmxAxEvent[] {
|
|
2175
2303
|
return this._db ? loadAxEventsFromDB(this._db, q) : [];
|
|
2176
2304
|
}
|
|
@@ -108,22 +108,60 @@ function injectIntoHead(html: string, content: string): string {
|
|
|
108
108
|
* injected when the node's AX capabilities are enabled (opt-in for `html`), and
|
|
109
109
|
* the server re-validates every interaction — so this is a convenience surface,
|
|
110
110
|
* not a trust boundary.
|
|
111
|
+
*
|
|
112
|
+
* `emit` returns a Promise that resolves with the interaction result once the
|
|
113
|
+
* parent acks it (report #55 — built-in confirmation so a click no longer looks
|
|
114
|
+
* like "nothing happened"). Authors can also `window.PMX_AX.on('ack', cb)` or
|
|
115
|
+
* listen for the `pmx-ax-ack` CustomEvent. Resolves with an `ax-ack-timeout`
|
|
116
|
+
* result after 10s if no ack arrives (e.g. an older parent), so `await emit()`
|
|
117
|
+
* never hangs.
|
|
111
118
|
*/
|
|
112
119
|
export function buildAxBridge(axToken: string, nodeId: string): string {
|
|
113
120
|
const token = JSON.stringify(axToken);
|
|
114
121
|
const node = JSON.stringify(nodeId);
|
|
115
122
|
return `<script data-pmx-canvas-ax-bridge>
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
window.PMX_AX
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
(function () {
|
|
124
|
+
const PMX_AX_TOKEN = ${token};
|
|
125
|
+
const PMX_AX_NODE_ID = ${node};
|
|
126
|
+
window.PMX_AX = window.PMX_AX || {};
|
|
127
|
+
const pending = new Map();
|
|
128
|
+
const ackListeners = [];
|
|
129
|
+
let seq = 0;
|
|
130
|
+
window.PMX_AX.emit = function (type, payload) {
|
|
131
|
+
seq += 1;
|
|
132
|
+
const correlationId = PMX_AX_NODE_ID + '-' + seq + '-' + (Date.now ? Date.now() : 0);
|
|
133
|
+
window.parent.postMessage({
|
|
134
|
+
source: 'pmx-canvas-ax',
|
|
135
|
+
token: PMX_AX_TOKEN,
|
|
136
|
+
nodeId: PMX_AX_NODE_ID,
|
|
137
|
+
correlationId: correlationId,
|
|
138
|
+
interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
|
|
139
|
+
}, '*');
|
|
140
|
+
return new Promise(function (resolve) {
|
|
141
|
+
const timer = setTimeout(function () {
|
|
142
|
+
pending.delete(correlationId);
|
|
143
|
+
// Match the real reject shape ({ ok:false, status, code:string, error }) so a
|
|
144
|
+
// surface inspecting r.code/r.status sees a consistent type on timeout vs reject.
|
|
145
|
+
resolve({ ok: false, status: 504, code: 'ax-ack-timeout', error: 'ax-ack-timeout' });
|
|
146
|
+
}, 10000);
|
|
147
|
+
pending.set(correlationId, function (result) { clearTimeout(timer); resolve(result); });
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
window.PMX_AX.on = function (eventType, cb) {
|
|
151
|
+
if (eventType === 'ack' && typeof cb === 'function') ackListeners.push(cb);
|
|
152
|
+
};
|
|
153
|
+
window.addEventListener('message', function (event) {
|
|
154
|
+
const m = event.data;
|
|
155
|
+
if (!m || m.source !== 'pmx-canvas-ax-ack' || m.token !== PMX_AX_TOKEN) return;
|
|
156
|
+
const result = m.result || { ok: false };
|
|
157
|
+
const resolver = m.correlationId ? pending.get(m.correlationId) : undefined;
|
|
158
|
+
if (resolver) { pending.delete(m.correlationId); resolver(result); }
|
|
159
|
+
for (let i = 0; i < ackListeners.length; i += 1) {
|
|
160
|
+
try { ackListeners[i](result, m.interaction || null); } catch (e) {}
|
|
161
|
+
}
|
|
162
|
+
try { window.dispatchEvent(new CustomEvent('pmx-ax-ack', { detail: { result: result, interaction: m.interaction || null } })); } catch (e) {}
|
|
163
|
+
});
|
|
164
|
+
})();
|
|
127
165
|
</script>`;
|
|
128
166
|
}
|
|
129
167
|
|
package/src/server/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { canvasState, IMAGE_MIME_MAP } from './canvas-state.js';
|
|
|
3
3
|
import type { CanvasAnnotation, CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from './canvas-state.js';
|
|
4
4
|
import { buildCanvasAxContext } from './ax-context.js';
|
|
5
5
|
import { applyAxInteraction, type AxInteractionInput, type AxInteractionPublicResult } from './ax-interaction.js';
|
|
6
|
+
import { waitForAxResolution } from './ax-wait.js';
|
|
6
7
|
import type {
|
|
8
|
+
PmxAxActivityKind,
|
|
7
9
|
PmxAxApprovalGate,
|
|
8
10
|
PmxAxCommandDescriptor,
|
|
9
11
|
PmxAxContext,
|
|
@@ -512,8 +514,8 @@ export class PmxCanvas extends EventEmitter {
|
|
|
512
514
|
return canvasState.getAxState();
|
|
513
515
|
}
|
|
514
516
|
|
|
515
|
-
getAxContext(): PmxAxContext {
|
|
516
|
-
return buildCanvasAxContext();
|
|
517
|
+
getAxContext(options?: { consumer?: string }): PmxAxContext {
|
|
518
|
+
return buildCanvasAxContext(options?.consumer);
|
|
517
519
|
}
|
|
518
520
|
|
|
519
521
|
setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): PmxAxFocusState {
|
|
@@ -710,6 +712,84 @@ export class PmxCanvas extends EventEmitter {
|
|
|
710
712
|
return modeRequest;
|
|
711
713
|
}
|
|
712
714
|
|
|
715
|
+
// ── Activity ingestion (primitive A — bidirectional board) ────────
|
|
716
|
+
ingestActivity(
|
|
717
|
+
input: {
|
|
718
|
+
kind: PmxAxActivityKind;
|
|
719
|
+
title: string;
|
|
720
|
+
summary?: string | null;
|
|
721
|
+
outcome?: 'success' | 'failure';
|
|
722
|
+
ref?: string | null;
|
|
723
|
+
nodeIds?: string[];
|
|
724
|
+
data?: Record<string, unknown> | null;
|
|
725
|
+
reactions?: {
|
|
726
|
+
workItem?: false | { status?: PmxAxWorkItemStatus; detail?: string | null };
|
|
727
|
+
evidence?: false | { kind?: PmxAxEvidenceKind; body?: string | null };
|
|
728
|
+
review?: false | { severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind; anchorType?: PmxAxReviewAnchorType; nodeId?: string | null };
|
|
729
|
+
};
|
|
730
|
+
},
|
|
731
|
+
options?: { source?: PmxAxSource },
|
|
732
|
+
): { event: PmxAxEvent; workItem: PmxAxWorkItem | null; evidence: PmxAxEvidence | null; review: PmxAxReviewAnnotation | null } {
|
|
733
|
+
const result = canvasState.ingestActivity(input, { source: options?.source ?? 'sdk' });
|
|
734
|
+
emitPrimaryWorkbenchEvent('ax-event-created', { event: result.event });
|
|
735
|
+
if (result.workItem) emitPrimaryWorkbenchEvent('ax-state-changed', { workItem: result.workItem });
|
|
736
|
+
if (result.evidence) emitPrimaryWorkbenchEvent('ax-event-created', { evidence: result.evidence });
|
|
737
|
+
if (result.review) emitPrimaryWorkbenchEvent('ax-state-changed', { reviewAnnotation: result.review });
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Single-item readers + blocking waits (primitive D — gates that gate) ──
|
|
742
|
+
getApproval(id: string): PmxAxApprovalGate | null {
|
|
743
|
+
return canvasState.getApproval(id);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
getElicitation(id: string): PmxAxElicitation | null {
|
|
747
|
+
return canvasState.getElicitation(id);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
getModeRequest(id: string): PmxAxModeRequest | null {
|
|
751
|
+
return canvasState.getModeRequest(id);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async awaitApproval(
|
|
755
|
+
id: string,
|
|
756
|
+
options?: { timeoutMs?: number; signal?: AbortSignal },
|
|
757
|
+
): Promise<{ approvalGate: PmxAxApprovalGate | null; pending: boolean }> {
|
|
758
|
+
const { value, pending } = await waitForAxResolution<PmxAxApprovalGate>({
|
|
759
|
+
read: () => canvasState.getApproval(id),
|
|
760
|
+
isResolved: (g) => g.status !== 'pending',
|
|
761
|
+
timeoutMs: options?.timeoutMs ?? 30000,
|
|
762
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
763
|
+
});
|
|
764
|
+
return { approvalGate: value, pending };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
async awaitElicitation(
|
|
768
|
+
id: string,
|
|
769
|
+
options?: { timeoutMs?: number; signal?: AbortSignal },
|
|
770
|
+
): Promise<{ elicitation: PmxAxElicitation | null; pending: boolean }> {
|
|
771
|
+
const { value, pending } = await waitForAxResolution<PmxAxElicitation>({
|
|
772
|
+
read: () => canvasState.getElicitation(id),
|
|
773
|
+
isResolved: (e) => e.status !== 'pending',
|
|
774
|
+
timeoutMs: options?.timeoutMs ?? 30000,
|
|
775
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
776
|
+
});
|
|
777
|
+
return { elicitation: value, pending };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async awaitMode(
|
|
781
|
+
id: string,
|
|
782
|
+
options?: { timeoutMs?: number; signal?: AbortSignal },
|
|
783
|
+
): Promise<{ modeRequest: PmxAxModeRequest | null; pending: boolean }> {
|
|
784
|
+
const { value, pending } = await waitForAxResolution<PmxAxModeRequest>({
|
|
785
|
+
read: () => canvasState.getModeRequest(id),
|
|
786
|
+
isResolved: (m) => m.status !== 'pending',
|
|
787
|
+
timeoutMs: options?.timeoutMs ?? 30000,
|
|
788
|
+
...(options?.signal ? { signal: options.signal } : {}),
|
|
789
|
+
});
|
|
790
|
+
return { modeRequest: value, pending };
|
|
791
|
+
}
|
|
792
|
+
|
|
713
793
|
getCommandRegistry(): PmxAxCommandDescriptor[] {
|
|
714
794
|
return canvasState.getCommandRegistry();
|
|
715
795
|
}
|