pmx-canvas 0.1.36 → 0.2.1
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 +447 -0
- package/Readme.md +2 -2
- package/dist/json-render/index.js +89 -334
- package/dist/types/mcp/canvas-access.d.ts +5 -171
- package/dist/types/server/ax-state-manager.d.ts +267 -0
- package/dist/types/server/ax-state.d.ts +3 -1
- package/dist/types/server/canvas-db.d.ts +13 -0
- package/dist/types/server/canvas-operations.d.ts +1 -12
- package/dist/types/server/canvas-state.d.ts +8 -23
- package/dist/types/server/index.d.ts +6 -24
- package/dist/types/server/operations/composites.d.ts +121 -0
- package/dist/types/server/operations/http.d.ts +7 -0
- package/dist/types/server/operations/index.d.ts +8 -0
- package/dist/types/server/operations/invoker.d.ts +13 -0
- package/dist/types/server/operations/mcp.d.ts +15 -0
- package/dist/types/server/operations/ops/annotation.d.ts +2 -0
- package/dist/types/server/operations/ops/app.d.ts +33 -0
- package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
- package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
- package/dist/types/server/operations/ops/batch.d.ts +19 -0
- package/dist/types/server/operations/ops/edges.d.ts +2 -0
- package/dist/types/server/operations/ops/groups.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +31 -0
- package/dist/types/server/operations/ops/nodes.d.ts +62 -0
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
- package/dist/types/server/operations/ops/validate.d.ts +2 -0
- package/dist/types/server/operations/ops/viewport.d.ts +2 -0
- package/dist/types/server/operations/ops/webview.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +15 -0
- package/dist/types/server/operations/types.d.ts +116 -0
- package/dist/types/server/operations/webview-runner.d.ts +69 -0
- package/docs/RELEASE.md +5 -0
- package/docs/adr-001-bun-only-runtime.md +46 -0
- package/docs/api-stability.md +57 -0
- package/docs/ax-host-adapter-contract.md +19 -1
- package/docs/ax-state-contract.md +72 -0
- package/docs/http-api.md +4 -0
- package/docs/mcp.md +61 -12
- package/docs/plans/plan-005-operation-registry.md +84 -0
- package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
- package/docs/plans/plan-007-ax-domain.md +99 -0
- package/docs/plans/plan-008-registry-finish.md +91 -0
- package/docs/tech-debt-assessment-2026-06.md +90 -0
- package/package.json +3 -3
- package/skills/pmx-canvas/SKILL.md +221 -193
- package/skills/pmx-canvas/evals/evals.json +3 -3
- package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
- package/src/cli/agent.ts +52 -31
- package/src/mcp/canvas-access.ts +30 -830
- package/src/mcp/server.ts +162 -2014
- package/src/server/ax-context.ts +8 -1
- package/src/server/ax-state-manager.ts +826 -0
- package/src/server/ax-state.ts +10 -2
- package/src/server/canvas-db.ts +35 -0
- package/src/server/canvas-operations.ts +2 -328
- package/src/server/canvas-schema.ts +2 -2
- package/src/server/canvas-state.ts +103 -465
- package/src/server/index.ts +54 -190
- package/src/server/operations/composites.ts +355 -0
- package/src/server/operations/http.ts +103 -0
- package/src/server/operations/index.ts +65 -0
- package/src/server/operations/invoker.ts +87 -0
- package/src/server/operations/mcp.ts +221 -0
- package/src/server/operations/ops/annotation.ts +60 -0
- package/src/server/operations/ops/app.ts +447 -0
- package/src/server/operations/ops/ax-await.ts +216 -0
- package/src/server/operations/ops/ax-shared.ts +38 -0
- package/src/server/operations/ops/ax-state.ts +249 -0
- package/src/server/operations/ops/ax-timeline.ts +381 -0
- package/src/server/operations/ops/ax-work.ts +635 -0
- package/src/server/operations/ops/batch.ts +365 -0
- package/src/server/operations/ops/edges.ts +166 -0
- package/src/server/operations/ops/groups.ts +176 -0
- package/src/server/operations/ops/json-render.ts +691 -0
- package/src/server/operations/ops/nodes.ts +1047 -0
- package/src/server/operations/ops/query.ts +281 -0
- package/src/server/operations/ops/snapshots.ts +366 -0
- package/src/server/operations/ops/validate.ts +37 -0
- package/src/server/operations/ops/viewport.ts +219 -0
- package/src/server/operations/ops/webview.ts +339 -0
- package/src/server/operations/registry.ts +79 -0
- package/src/server/operations/types.ts +150 -0
- package/src/server/operations/webview-runner.ts +77 -0
- package/src/server/server.ts +158 -2255
- package/src/server/web-artifacts.ts +6 -2
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-007 Slice B.1 (wave 2) operations: the canvas-bound AX work / review /
|
|
3
|
+
* gate mutators —
|
|
4
|
+
* ax.work.create / ax.work.update
|
|
5
|
+
* ax.review.add / ax.review.update
|
|
6
|
+
* ax.approval.request / ax.approval.resolve
|
|
7
|
+
* ax.elicitation.request / ax.elicitation.respond
|
|
8
|
+
* ax.mode.request / ax.mode.resolve
|
|
9
|
+
*
|
|
10
|
+
* Like the wave-1 state ops, none of these change the node/edge layout, so
|
|
11
|
+
* every op is `mutates: false` (no `canvas-layout-update`). They emit the SAME
|
|
12
|
+
* AX SSE frame the legacy handlers emitted — `ax-state-changed` via `ctx.emit`
|
|
13
|
+
* with the same single-key payload (`{ workItem }`, `{ approvalGate }`, …) —
|
|
14
|
+
* and the injected emitter adds the `sessionId`/`timestamp` envelope fields
|
|
15
|
+
* (see server.ts emitPrimaryWorkbenchEvent).
|
|
16
|
+
*
|
|
17
|
+
* Source defaulting matches the legacy surfaces exactly: MCP `buildInput`
|
|
18
|
+
* injects `source: 'mcp'`; the HTTP handlers default an absent source to 'api'.
|
|
19
|
+
*
|
|
20
|
+
* id-from-path ops (update / resolve / respond): the HTTP route carries `:id`;
|
|
21
|
+
* the default readInput merges it into `input.id` (path params win), and the
|
|
22
|
+
* HttpOperationInvoker fills `:id` from input. The handler reads `input.id`.
|
|
23
|
+
*
|
|
24
|
+
* Cross-surface unification (documented; same class as wave 1 / plan-005):
|
|
25
|
+
* the legacy MCP tools for update/resolve/respond returned a SUCCESS-shaped
|
|
26
|
+
* `{ ok: false, <item>: null }` (NOT isError) on a missing/already-resolved
|
|
27
|
+
* target, while the legacy HTTP route returned a 404 `{ ok:false, error }`.
|
|
28
|
+
* One op = one wire body: the op throws `OperationError(..., 404)`, so the HTTP
|
|
29
|
+
* body is byte-identical to the legacy 404 and the MCP tool surfaces it as an
|
|
30
|
+
* `isError` result with the message text (the registry-wide local-vs-remote
|
|
31
|
+
* unification — the SUCCESS shapes the tests assert are preserved exactly).
|
|
32
|
+
*
|
|
33
|
+
* Denial bodies preserved byte-for-byte:
|
|
34
|
+
* - ax.review.add: node-anchored review with a missing/unknown nodeId → 400
|
|
35
|
+
* "node-anchored review annotation requires a nodeId that exists on the canvas."
|
|
36
|
+
* - ax.approval.resolve / ax.mode.resolve: missing or already-resolved gate →
|
|
37
|
+
* 404 "approval gate not found or already resolved." /
|
|
38
|
+
* "mode request not found or already resolved."
|
|
39
|
+
* - ax.work.update: missing work item → 404 "work item not found."
|
|
40
|
+
* - ax.elicitation.respond: not found/already answered → 404
|
|
41
|
+
* "elicitation not found or already answered."
|
|
42
|
+
* - ax.review.update: not found → 404 "review annotation not found."
|
|
43
|
+
* - invalid work-item status (#56) → 400; invalid mode → 400.
|
|
44
|
+
*
|
|
45
|
+
* This module must never import server.ts or index.ts.
|
|
46
|
+
*/
|
|
47
|
+
import { z } from 'zod';
|
|
48
|
+
import { canvasState } from '../../canvas-state.js';
|
|
49
|
+
import type {
|
|
50
|
+
PmxAxMode,
|
|
51
|
+
PmxAxReviewAnchorType,
|
|
52
|
+
PmxAxReviewKind,
|
|
53
|
+
PmxAxReviewRegion,
|
|
54
|
+
PmxAxReviewSeverity,
|
|
55
|
+
PmxAxReviewStatus,
|
|
56
|
+
PmxAxWorkItemStatus,
|
|
57
|
+
} from '../../ax-state.js';
|
|
58
|
+
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
59
|
+
import { isRecord } from './nodes.js';
|
|
60
|
+
import { AX_SOURCE_SHAPE, AX_SOURCES, axJsonResult, normalizeAxNodeIds, normalizeAxSource } from './ax-shared.js';
|
|
61
|
+
|
|
62
|
+
const AX_WORK_ITEM_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
|
|
63
|
+
function normalizeAxWorkItemStatus(value: unknown): PmxAxWorkItemStatus | undefined {
|
|
64
|
+
return typeof value === 'string' && AX_WORK_ITEM_STATUSES.has(value)
|
|
65
|
+
? value as PmxAxWorkItemStatus
|
|
66
|
+
: undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
|
|
70
|
+
const AX_REVIEW_SEVERITIES = new Set(['info', 'warning', 'error']);
|
|
71
|
+
const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
|
|
72
|
+
const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
|
|
73
|
+
|
|
74
|
+
function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
|
|
75
|
+
return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
|
|
76
|
+
}
|
|
77
|
+
function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
|
|
78
|
+
return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
|
|
79
|
+
}
|
|
80
|
+
function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
|
|
81
|
+
return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
|
|
82
|
+
}
|
|
83
|
+
function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
|
|
84
|
+
return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
|
|
85
|
+
}
|
|
86
|
+
function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
|
|
87
|
+
if (!isRecord(value)) return undefined;
|
|
88
|
+
return {
|
|
89
|
+
...(typeof value.line === 'number' ? { line: value.line } : {}),
|
|
90
|
+
...(typeof value.endLine === 'number' ? { endLine: value.endLine } : {}),
|
|
91
|
+
...(typeof value.label === 'string' ? { label: value.label } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── ax.work.create (canvas_add_work_item) ─────────────────────
|
|
96
|
+
|
|
97
|
+
const axWorkCreateShape = {
|
|
98
|
+
title: z.unknown().optional().describe('Short title of the work item.'),
|
|
99
|
+
status: z.unknown().optional().describe('Work item status. Defaults to todo.'),
|
|
100
|
+
detail: z.unknown().optional().describe('Optional longer description.'),
|
|
101
|
+
nodeIds: z.unknown().optional().describe('Optional node IDs this work item is tied to.'),
|
|
102
|
+
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const axWorkCreateSchema = z.looseObject(axWorkCreateShape);
|
|
106
|
+
|
|
107
|
+
const axWorkCreateOperation = defineOperation<z.infer<typeof axWorkCreateSchema>, Record<string, unknown>>({
|
|
108
|
+
name: 'ax.work.create',
|
|
109
|
+
mutates: false,
|
|
110
|
+
input: axWorkCreateSchema,
|
|
111
|
+
inputShape: axWorkCreateShape,
|
|
112
|
+
http: {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
path: '/api/canvas/ax/work',
|
|
115
|
+
},
|
|
116
|
+
mcp: {
|
|
117
|
+
toolName: 'canvas_add_work_item',
|
|
118
|
+
description: 'Add a canvas-bound AX work item: a visible task/plan/status tied to nodes and agent work. Work items participate in snapshots and are exposed via canvas://ax-work.',
|
|
119
|
+
extraShape: {
|
|
120
|
+
title: z.string().describe('Short title of the work item.'),
|
|
121
|
+
status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
|
|
122
|
+
.optional()
|
|
123
|
+
.describe('Work item status. Defaults to todo.'),
|
|
124
|
+
detail: z.string().optional().describe('Optional longer description.'),
|
|
125
|
+
nodeIds: z.array(z.string()).optional().describe('Optional node IDs this work item is tied to.'),
|
|
126
|
+
source: AX_SOURCE_SHAPE,
|
|
127
|
+
},
|
|
128
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
129
|
+
formatResult: axJsonResult,
|
|
130
|
+
},
|
|
131
|
+
handler: (input, ctx) => {
|
|
132
|
+
if (typeof input.title !== 'string' || !input.title.trim()) {
|
|
133
|
+
throw new OperationError('work item requires a title.');
|
|
134
|
+
}
|
|
135
|
+
// Report #56: reject an unknown status (e.g. "in_progress") instead of
|
|
136
|
+
// silently dropping it — the accepted tokens use hyphens.
|
|
137
|
+
if (input.status !== undefined && !normalizeAxWorkItemStatus(input.status)) {
|
|
138
|
+
throw new OperationError(`invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`);
|
|
139
|
+
}
|
|
140
|
+
const status = normalizeAxWorkItemStatus(input.status);
|
|
141
|
+
const workItem = canvasState.addWorkItem(
|
|
142
|
+
{
|
|
143
|
+
title: input.title,
|
|
144
|
+
...(status ? { status } : {}),
|
|
145
|
+
...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
|
|
146
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
147
|
+
},
|
|
148
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
149
|
+
);
|
|
150
|
+
ctx.emit('ax-state-changed', { workItem });
|
|
151
|
+
return { ok: true, workItem } as unknown as Record<string, unknown>;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── ax.work.update (canvas_update_work_item) ──────────────────
|
|
156
|
+
|
|
157
|
+
const axWorkUpdateShape = {
|
|
158
|
+
id: z.string().optional().catch(undefined).describe('Work item ID to update.'),
|
|
159
|
+
title: z.unknown().optional().describe('New title.'),
|
|
160
|
+
status: z.unknown().optional().describe('New status.'),
|
|
161
|
+
detail: z.unknown().optional().describe('New detail text.'),
|
|
162
|
+
nodeIds: z.unknown().optional().describe('Replacement node IDs.'),
|
|
163
|
+
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const axWorkUpdateSchema = z.looseObject(axWorkUpdateShape);
|
|
167
|
+
|
|
168
|
+
const axWorkUpdateOperation = defineOperation<z.infer<typeof axWorkUpdateSchema>, Record<string, unknown>>({
|
|
169
|
+
name: 'ax.work.update',
|
|
170
|
+
mutates: false,
|
|
171
|
+
input: axWorkUpdateSchema,
|
|
172
|
+
inputShape: axWorkUpdateShape,
|
|
173
|
+
http: {
|
|
174
|
+
method: 'PATCH',
|
|
175
|
+
path: '/api/canvas/ax/work/:id',
|
|
176
|
+
},
|
|
177
|
+
mcp: {
|
|
178
|
+
toolName: 'canvas_update_work_item',
|
|
179
|
+
description: 'Update a canvas-bound AX work item by ID (title/status/detail/nodeIds). Returns null if the work item does not exist.',
|
|
180
|
+
extraShape: {
|
|
181
|
+
id: z.string().describe('Work item ID to update.'),
|
|
182
|
+
title: z.string().optional().describe('New title.'),
|
|
183
|
+
status: z.enum(['todo', 'in-progress', 'blocked', 'done', 'cancelled'])
|
|
184
|
+
.optional()
|
|
185
|
+
.describe('New status.'),
|
|
186
|
+
detail: z.string().optional().describe('New detail text.'),
|
|
187
|
+
nodeIds: z.array(z.string()).optional().describe('Replacement node IDs.'),
|
|
188
|
+
source: AX_SOURCE_SHAPE,
|
|
189
|
+
},
|
|
190
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
191
|
+
formatResult: axJsonResult,
|
|
192
|
+
},
|
|
193
|
+
handler: (input, ctx) => {
|
|
194
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
195
|
+
// Report #56: reject an unknown status instead of returning ok:true + no-op.
|
|
196
|
+
if (input.status !== undefined && !normalizeAxWorkItemStatus(input.status)) {
|
|
197
|
+
throw new OperationError(`invalid work item status "${String(input.status)}"; expected one of: todo, in-progress, blocked, done, cancelled.`);
|
|
198
|
+
}
|
|
199
|
+
const status = normalizeAxWorkItemStatus(input.status);
|
|
200
|
+
const workItem = canvasState.updateWorkItem(
|
|
201
|
+
id,
|
|
202
|
+
{
|
|
203
|
+
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
|
204
|
+
...(status ? { status } : {}),
|
|
205
|
+
...(typeof input.detail === 'string' || input.detail === null ? { detail: input.detail as string | null } : {}),
|
|
206
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
207
|
+
},
|
|
208
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
209
|
+
);
|
|
210
|
+
if (!workItem) throw new OperationError('work item not found.', 404);
|
|
211
|
+
ctx.emit('ax-state-changed', { workItem });
|
|
212
|
+
return { ok: true, workItem } as unknown as Record<string, unknown>;
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── ax.review.add (canvas_add_review_annotation) ──────────────
|
|
217
|
+
|
|
218
|
+
const axReviewAddShape = {
|
|
219
|
+
body: z.unknown().optional().describe('Annotation body text.'),
|
|
220
|
+
kind: z.unknown().optional().describe('Annotation kind. Default comment.'),
|
|
221
|
+
severity: z.unknown().optional().describe('Severity. Default info.'),
|
|
222
|
+
anchorType: z.unknown().optional().describe('Anchor type. Default node.'),
|
|
223
|
+
nodeId: z.unknown().optional().describe('Node ID when anchorType is node.'),
|
|
224
|
+
file: z.unknown().optional().describe('File path when anchorType is file.'),
|
|
225
|
+
region: z.unknown().optional().describe('Region descriptor when anchorType is region.'),
|
|
226
|
+
author: z.unknown().optional().describe('Optional author label.'),
|
|
227
|
+
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const axReviewAddSchema = z.looseObject(axReviewAddShape);
|
|
231
|
+
|
|
232
|
+
const axReviewAddOperation = defineOperation<z.infer<typeof axReviewAddSchema>, Record<string, unknown>>({
|
|
233
|
+
name: 'ax.review.add',
|
|
234
|
+
mutates: false,
|
|
235
|
+
input: axReviewAddSchema,
|
|
236
|
+
inputShape: axReviewAddShape,
|
|
237
|
+
http: {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
path: '/api/canvas/ax/review',
|
|
240
|
+
},
|
|
241
|
+
mcp: {
|
|
242
|
+
toolName: 'canvas_add_review_annotation',
|
|
243
|
+
description: 'Add a canvas-bound review annotation: a comment or finding anchored to a node, file, or region. Review annotations participate in snapshots and are exposed via canvas://ax-work.',
|
|
244
|
+
extraShape: {
|
|
245
|
+
body: z.string().describe('Annotation body text.'),
|
|
246
|
+
kind: z.enum(['comment', 'finding']).optional().describe('Annotation kind. Default comment.'),
|
|
247
|
+
severity: z.enum(['info', 'warning', 'error']).optional().describe('Severity. Default info.'),
|
|
248
|
+
anchorType: z.enum(['node', 'file', 'region']).optional().describe('Anchor type. Default node.'),
|
|
249
|
+
nodeId: z.string().optional().describe('Node ID when anchorType is node.'),
|
|
250
|
+
file: z.string().optional().describe('File path when anchorType is file.'),
|
|
251
|
+
region: z.object({
|
|
252
|
+
line: z.number().optional(),
|
|
253
|
+
endLine: z.number().optional(),
|
|
254
|
+
label: z.string().optional(),
|
|
255
|
+
}).optional().describe('Region descriptor when anchorType is region.'),
|
|
256
|
+
author: z.string().optional().describe('Optional author label.'),
|
|
257
|
+
source: AX_SOURCE_SHAPE,
|
|
258
|
+
},
|
|
259
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
260
|
+
formatResult: axJsonResult,
|
|
261
|
+
},
|
|
262
|
+
handler: (input, ctx) => {
|
|
263
|
+
if (typeof input.body !== 'string' || !input.body.trim()) {
|
|
264
|
+
throw new OperationError('review annotation requires a body.');
|
|
265
|
+
}
|
|
266
|
+
const kind = normalizeAxReviewKind(input.kind);
|
|
267
|
+
const severity = normalizeAxReviewSeverity(input.severity);
|
|
268
|
+
const anchorType = normalizeAxReviewAnchor(input.anchorType);
|
|
269
|
+
const region = normalizeAxReviewRegion(input.region);
|
|
270
|
+
const reviewAnnotation = canvasState.addReviewAnnotation(
|
|
271
|
+
{
|
|
272
|
+
body: input.body,
|
|
273
|
+
...(kind ? { kind } : {}),
|
|
274
|
+
...(severity ? { severity } : {}),
|
|
275
|
+
...(anchorType ? { anchorType } : {}),
|
|
276
|
+
...(typeof input.nodeId === 'string' ? { nodeId: input.nodeId } : {}),
|
|
277
|
+
...(typeof input.file === 'string' ? { file: input.file } : {}),
|
|
278
|
+
...(region ? { region } : {}),
|
|
279
|
+
...(typeof input.author === 'string' ? { author: input.author } : {}),
|
|
280
|
+
},
|
|
281
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
282
|
+
);
|
|
283
|
+
if (!reviewAnnotation) {
|
|
284
|
+
// Denial body preserved byte-for-byte; legacy HTTP status was 400.
|
|
285
|
+
throw new OperationError('node-anchored review annotation requires a nodeId that exists on the canvas.', 400);
|
|
286
|
+
}
|
|
287
|
+
ctx.emit('ax-state-changed', { reviewAnnotation });
|
|
288
|
+
return { ok: true, reviewAnnotation } as unknown as Record<string, unknown>;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── ax.review.update (HTTP only — no MCP tool) ────────────────
|
|
293
|
+
|
|
294
|
+
const axReviewUpdateShape = {
|
|
295
|
+
id: z.string().optional().catch(undefined).describe('Review annotation ID to update.'),
|
|
296
|
+
body: z.unknown().optional(),
|
|
297
|
+
status: z.unknown().optional(),
|
|
298
|
+
severity: z.unknown().optional(),
|
|
299
|
+
kind: z.unknown().optional(),
|
|
300
|
+
source: z.unknown().optional(),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const axReviewUpdateSchema = z.looseObject(axReviewUpdateShape);
|
|
304
|
+
|
|
305
|
+
const axReviewUpdateOperation = defineOperation<z.infer<typeof axReviewUpdateSchema>, Record<string, unknown>>({
|
|
306
|
+
name: 'ax.review.update',
|
|
307
|
+
mutates: false,
|
|
308
|
+
input: axReviewUpdateSchema,
|
|
309
|
+
inputShape: axReviewUpdateShape,
|
|
310
|
+
http: {
|
|
311
|
+
method: 'PATCH',
|
|
312
|
+
path: '/api/canvas/ax/review/:id',
|
|
313
|
+
},
|
|
314
|
+
// HTTP-only: the legacy surface had no MCP tool for review update.
|
|
315
|
+
handler: (input, ctx) => {
|
|
316
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
317
|
+
const status = normalizeAxReviewStatus(input.status);
|
|
318
|
+
const severity = normalizeAxReviewSeverity(input.severity);
|
|
319
|
+
const kind = normalizeAxReviewKind(input.kind);
|
|
320
|
+
const reviewAnnotation = canvasState.updateReviewAnnotation(
|
|
321
|
+
id,
|
|
322
|
+
{
|
|
323
|
+
...(typeof input.body === 'string' ? { body: input.body } : {}),
|
|
324
|
+
...(status ? { status } : {}),
|
|
325
|
+
...(severity ? { severity } : {}),
|
|
326
|
+
...(kind ? { kind } : {}),
|
|
327
|
+
},
|
|
328
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
329
|
+
);
|
|
330
|
+
if (!reviewAnnotation) throw new OperationError('review annotation not found.', 404);
|
|
331
|
+
ctx.emit('ax-state-changed', { reviewAnnotation });
|
|
332
|
+
return { ok: true, reviewAnnotation } as unknown as Record<string, unknown>;
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── ax.approval.request (canvas_request_approval) ─────────────
|
|
337
|
+
|
|
338
|
+
const axApprovalRequestShape = {
|
|
339
|
+
title: z.unknown().optional().describe('Short title of what needs approval.'),
|
|
340
|
+
detail: z.unknown().optional().describe('Optional explanation of the action and its impact.'),
|
|
341
|
+
action: z.unknown().optional().describe('Optional machine-readable action identifier the approval gates.'),
|
|
342
|
+
nodeIds: z.unknown().optional().describe('Optional node IDs this approval relates to.'),
|
|
343
|
+
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const axApprovalRequestSchema = z.looseObject(axApprovalRequestShape);
|
|
347
|
+
|
|
348
|
+
const axApprovalRequestOperation = defineOperation<z.infer<typeof axApprovalRequestSchema>, Record<string, unknown>>({
|
|
349
|
+
name: 'ax.approval.request',
|
|
350
|
+
mutates: false,
|
|
351
|
+
input: axApprovalRequestSchema,
|
|
352
|
+
inputShape: axApprovalRequestShape,
|
|
353
|
+
http: {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
path: '/api/canvas/ax/approval',
|
|
356
|
+
},
|
|
357
|
+
mcp: {
|
|
358
|
+
toolName: 'canvas_request_approval',
|
|
359
|
+
description: 'Request human approval before a high-impact AX action: creates a pending approval gate tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work.',
|
|
360
|
+
extraShape: {
|
|
361
|
+
title: z.string().describe('Short title of what needs approval.'),
|
|
362
|
+
detail: z.string().optional().describe('Optional explanation of the action and its impact.'),
|
|
363
|
+
action: z.string().optional().describe('Optional machine-readable action identifier the approval gates.'),
|
|
364
|
+
nodeIds: z.array(z.string()).optional().describe('Optional node IDs this approval relates to.'),
|
|
365
|
+
source: AX_SOURCE_SHAPE,
|
|
366
|
+
},
|
|
367
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
368
|
+
formatResult: axJsonResult,
|
|
369
|
+
},
|
|
370
|
+
handler: (input, ctx) => {
|
|
371
|
+
if (typeof input.title !== 'string' || !input.title.trim()) {
|
|
372
|
+
throw new OperationError('approval request requires a title.');
|
|
373
|
+
}
|
|
374
|
+
const approvalGate = canvasState.requestApproval(
|
|
375
|
+
{
|
|
376
|
+
title: input.title,
|
|
377
|
+
...(typeof input.detail === 'string' ? { detail: input.detail } : {}),
|
|
378
|
+
...(typeof input.action === 'string' ? { action: input.action } : {}),
|
|
379
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
380
|
+
},
|
|
381
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
382
|
+
);
|
|
383
|
+
ctx.emit('ax-state-changed', { approvalGate });
|
|
384
|
+
return { ok: true, approvalGate } as unknown as Record<string, unknown>;
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// ── ax.approval.resolve (canvas_resolve_approval) ─────────────
|
|
389
|
+
|
|
390
|
+
const axApprovalResolveShape = {
|
|
391
|
+
id: z.string().optional().catch(undefined).describe('Approval gate ID to resolve.'),
|
|
392
|
+
decision: z.unknown().optional().describe('Approval decision.'),
|
|
393
|
+
resolution: z.unknown().optional().describe('Optional human-readable resolution note.'),
|
|
394
|
+
source: z.unknown().optional().describe('Optional host/source label. Defaults to mcp.'),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const axApprovalResolveSchema = z.looseObject(axApprovalResolveShape);
|
|
398
|
+
|
|
399
|
+
const axApprovalResolveOperation = defineOperation<z.infer<typeof axApprovalResolveSchema>, Record<string, unknown>>({
|
|
400
|
+
name: 'ax.approval.resolve',
|
|
401
|
+
mutates: false,
|
|
402
|
+
input: axApprovalResolveSchema,
|
|
403
|
+
inputShape: axApprovalResolveShape,
|
|
404
|
+
http: {
|
|
405
|
+
method: 'POST',
|
|
406
|
+
path: '/api/canvas/ax/approval/:id/resolve',
|
|
407
|
+
},
|
|
408
|
+
mcp: {
|
|
409
|
+
toolName: 'canvas_resolve_approval',
|
|
410
|
+
description: 'Resolve a pending approval gate by ID with approved or rejected. Returns null if the gate does not exist or is already resolved.',
|
|
411
|
+
extraShape: {
|
|
412
|
+
id: z.string().describe('Approval gate ID to resolve.'),
|
|
413
|
+
decision: z.enum(['approved', 'rejected']).describe('Approval decision.'),
|
|
414
|
+
resolution: z.string().optional().describe('Optional human-readable resolution note.'),
|
|
415
|
+
source: AX_SOURCE_SHAPE,
|
|
416
|
+
},
|
|
417
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
418
|
+
formatResult: axJsonResult,
|
|
419
|
+
},
|
|
420
|
+
handler: (input, ctx) => {
|
|
421
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
422
|
+
if (input.decision !== 'approved' && input.decision !== 'rejected') {
|
|
423
|
+
throw new OperationError('resolve requires decision approved or rejected.');
|
|
424
|
+
}
|
|
425
|
+
const approvalGate = canvasState.resolveApproval(
|
|
426
|
+
id,
|
|
427
|
+
input.decision,
|
|
428
|
+
{
|
|
429
|
+
...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
|
|
430
|
+
source: normalizeAxSource(input.source, 'api'),
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
if (!approvalGate) throw new OperationError('approval gate not found or already resolved.', 404);
|
|
434
|
+
ctx.emit('ax-state-changed', { approvalGate });
|
|
435
|
+
return { ok: true, approvalGate } as unknown as Record<string, unknown>;
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ── ax.elicitation.request (canvas_request_elicitation) ───────
|
|
440
|
+
|
|
441
|
+
const axElicitationRequestShape = {
|
|
442
|
+
prompt: z.unknown().optional().describe('The question or instruction for the human.'),
|
|
443
|
+
fields: z.unknown().optional().describe('Optional field names to request (a simple structured form).'),
|
|
444
|
+
nodeIds: z.unknown().optional(),
|
|
445
|
+
source: z.unknown().optional(),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const axElicitationRequestSchema = z.looseObject(axElicitationRequestShape);
|
|
449
|
+
|
|
450
|
+
const axElicitationRequestOperation = defineOperation<z.infer<typeof axElicitationRequestSchema>, Record<string, unknown>>({
|
|
451
|
+
name: 'ax.elicitation.request',
|
|
452
|
+
mutates: false,
|
|
453
|
+
input: axElicitationRequestSchema,
|
|
454
|
+
inputShape: axElicitationRequestShape,
|
|
455
|
+
http: {
|
|
456
|
+
method: 'POST',
|
|
457
|
+
path: '/api/canvas/ax/elicitation',
|
|
458
|
+
},
|
|
459
|
+
mcp: {
|
|
460
|
+
toolName: 'canvas_request_elicitation',
|
|
461
|
+
description: 'Request structured human input (an elicitation): a pending question/form tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Answer it with canvas_respond_elicitation.',
|
|
462
|
+
extraShape: {
|
|
463
|
+
prompt: z.string().describe('The question or instruction for the human.'),
|
|
464
|
+
fields: z.array(z.string()).optional().describe('Optional field names to request (a simple structured form).'),
|
|
465
|
+
nodeIds: z.array(z.string()).optional(),
|
|
466
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
467
|
+
},
|
|
468
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
469
|
+
formatResult: axJsonResult,
|
|
470
|
+
},
|
|
471
|
+
handler: (input, ctx) => {
|
|
472
|
+
if (typeof input.prompt !== 'string' || !input.prompt.trim()) {
|
|
473
|
+
throw new OperationError('elicitation requires a prompt.');
|
|
474
|
+
}
|
|
475
|
+
const elicitation = canvasState.requestElicitation(
|
|
476
|
+
{
|
|
477
|
+
prompt: input.prompt,
|
|
478
|
+
...(Array.isArray(input.fields) ? { fields: input.fields.filter((f): f is string => typeof f === 'string') } : {}),
|
|
479
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
480
|
+
},
|
|
481
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
482
|
+
);
|
|
483
|
+
ctx.emit('ax-state-changed', { elicitation });
|
|
484
|
+
return { ok: true, elicitation } as unknown as Record<string, unknown>;
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ── ax.elicitation.respond (canvas_respond_elicitation) ───────
|
|
489
|
+
|
|
490
|
+
const axElicitationRespondShape = {
|
|
491
|
+
id: z.string().optional().catch(undefined).describe('The elicitation id.'),
|
|
492
|
+
response: z.unknown().optional().describe('The structured answer.'),
|
|
493
|
+
source: z.unknown().optional(),
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const axElicitationRespondSchema = z.looseObject(axElicitationRespondShape);
|
|
497
|
+
|
|
498
|
+
const axElicitationRespondOperation = defineOperation<z.infer<typeof axElicitationRespondSchema>, Record<string, unknown>>({
|
|
499
|
+
name: 'ax.elicitation.respond',
|
|
500
|
+
mutates: false,
|
|
501
|
+
input: axElicitationRespondSchema,
|
|
502
|
+
inputShape: axElicitationRespondShape,
|
|
503
|
+
http: {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
path: '/api/canvas/ax/elicitation/:id/respond',
|
|
506
|
+
},
|
|
507
|
+
mcp: {
|
|
508
|
+
toolName: 'canvas_respond_elicitation',
|
|
509
|
+
description: 'Answer a pending elicitation with a structured response.',
|
|
510
|
+
extraShape: {
|
|
511
|
+
id: z.string().describe('The elicitation id.'),
|
|
512
|
+
response: z.record(z.string(), z.unknown()).describe('The structured answer.'),
|
|
513
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
514
|
+
},
|
|
515
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
516
|
+
formatResult: axJsonResult,
|
|
517
|
+
},
|
|
518
|
+
handler: (input, ctx) => {
|
|
519
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
520
|
+
const response = isRecord(input.response) ? input.response : {};
|
|
521
|
+
const elicitation = canvasState.respondElicitation(id, response, { source: normalizeAxSource(input.source, 'api') });
|
|
522
|
+
if (!elicitation) throw new OperationError('elicitation not found or already answered.', 404);
|
|
523
|
+
ctx.emit('ax-state-changed', { elicitation });
|
|
524
|
+
return { ok: true, elicitation } as unknown as Record<string, unknown>;
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ── ax.mode.request (canvas_request_mode) ─────────────────────
|
|
529
|
+
|
|
530
|
+
const axModeRequestShape = {
|
|
531
|
+
mode: z.unknown().optional().describe('Requested target mode.'),
|
|
532
|
+
reason: z.unknown().optional(),
|
|
533
|
+
nodeIds: z.unknown().optional(),
|
|
534
|
+
source: z.unknown().optional(),
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const axModeRequestSchema = z.looseObject(axModeRequestShape);
|
|
538
|
+
|
|
539
|
+
const axModeRequestOperation = defineOperation<z.infer<typeof axModeRequestSchema>, Record<string, unknown>>({
|
|
540
|
+
name: 'ax.mode.request',
|
|
541
|
+
mutates: false,
|
|
542
|
+
input: axModeRequestSchema,
|
|
543
|
+
inputShape: axModeRequestShape,
|
|
544
|
+
http: {
|
|
545
|
+
method: 'POST',
|
|
546
|
+
path: '/api/canvas/ax/mode',
|
|
547
|
+
},
|
|
548
|
+
mcp: {
|
|
549
|
+
toolName: 'canvas_request_mode',
|
|
550
|
+
description: 'Request a workflow mode transition (plan/execute/autonomous): a pending mode request tied to nodes. Canvas-bound and snapshotted; exposed via canvas://ax-work. Resolve with canvas_resolve_mode.',
|
|
551
|
+
extraShape: {
|
|
552
|
+
mode: z.enum(['plan', 'execute', 'autonomous']).describe('Requested target mode.'),
|
|
553
|
+
reason: z.string().optional(),
|
|
554
|
+
nodeIds: z.array(z.string()).optional(),
|
|
555
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
556
|
+
},
|
|
557
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
558
|
+
formatResult: axJsonResult,
|
|
559
|
+
},
|
|
560
|
+
handler: (input, ctx) => {
|
|
561
|
+
if (input.mode !== 'plan' && input.mode !== 'execute' && input.mode !== 'autonomous') {
|
|
562
|
+
throw new OperationError('mode request requires mode plan|execute|autonomous.');
|
|
563
|
+
}
|
|
564
|
+
const modeRequest = canvasState.requestMode(
|
|
565
|
+
{
|
|
566
|
+
mode: input.mode as PmxAxMode,
|
|
567
|
+
...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
|
|
568
|
+
...(Array.isArray(input.nodeIds) ? { nodeIds: normalizeAxNodeIds(input.nodeIds) } : {}),
|
|
569
|
+
},
|
|
570
|
+
{ source: normalizeAxSource(input.source, 'api') },
|
|
571
|
+
);
|
|
572
|
+
ctx.emit('ax-state-changed', { modeRequest });
|
|
573
|
+
return { ok: true, modeRequest } as unknown as Record<string, unknown>;
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ── ax.mode.resolve (canvas_resolve_mode) ─────────────────────
|
|
578
|
+
|
|
579
|
+
const axModeResolveShape = {
|
|
580
|
+
id: z.string().optional().catch(undefined),
|
|
581
|
+
decision: z.unknown().optional(),
|
|
582
|
+
resolution: z.unknown().optional(),
|
|
583
|
+
source: z.unknown().optional(),
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const axModeResolveSchema = z.looseObject(axModeResolveShape);
|
|
587
|
+
|
|
588
|
+
const axModeResolveOperation = defineOperation<z.infer<typeof axModeResolveSchema>, Record<string, unknown>>({
|
|
589
|
+
name: 'ax.mode.resolve',
|
|
590
|
+
mutates: false,
|
|
591
|
+
input: axModeResolveSchema,
|
|
592
|
+
inputShape: axModeResolveShape,
|
|
593
|
+
http: {
|
|
594
|
+
method: 'POST',
|
|
595
|
+
path: '/api/canvas/ax/mode/:id/resolve',
|
|
596
|
+
},
|
|
597
|
+
mcp: {
|
|
598
|
+
toolName: 'canvas_resolve_mode',
|
|
599
|
+
description: 'Resolve a pending mode request (approved or rejected).',
|
|
600
|
+
extraShape: {
|
|
601
|
+
id: z.string(),
|
|
602
|
+
decision: z.enum(['approved', 'rejected']),
|
|
603
|
+
resolution: z.string().optional(),
|
|
604
|
+
source: z.enum(AX_SOURCES).optional(),
|
|
605
|
+
},
|
|
606
|
+
buildInput: (input) => ({ ...input, source: normalizeAxSource(input.source, 'mcp') }),
|
|
607
|
+
formatResult: axJsonResult,
|
|
608
|
+
},
|
|
609
|
+
handler: (input, ctx) => {
|
|
610
|
+
const id = typeof input.id === 'string' ? input.id : '';
|
|
611
|
+
if (input.decision !== 'approved' && input.decision !== 'rejected') {
|
|
612
|
+
throw new OperationError('resolve requires decision approved or rejected.');
|
|
613
|
+
}
|
|
614
|
+
const modeRequest = canvasState.resolveModeRequest(id, input.decision, {
|
|
615
|
+
...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}),
|
|
616
|
+
source: normalizeAxSource(input.source, 'api'),
|
|
617
|
+
});
|
|
618
|
+
if (!modeRequest) throw new OperationError('mode request not found or already resolved.', 404);
|
|
619
|
+
ctx.emit('ax-state-changed', { modeRequest });
|
|
620
|
+
return { ok: true, modeRequest } as unknown as Record<string, unknown>;
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
export const axWorkOperations: Operation[] = [
|
|
625
|
+
axWorkCreateOperation,
|
|
626
|
+
axWorkUpdateOperation,
|
|
627
|
+
axReviewAddOperation,
|
|
628
|
+
axReviewUpdateOperation,
|
|
629
|
+
axApprovalRequestOperation,
|
|
630
|
+
axApprovalResolveOperation,
|
|
631
|
+
axElicitationRequestOperation,
|
|
632
|
+
axElicitationRespondOperation,
|
|
633
|
+
axModeRequestOperation,
|
|
634
|
+
axModeResolveOperation,
|
|
635
|
+
];
|