pmx-canvas 0.1.26 → 0.1.28
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/.github/extensions/pmx-canvas/extension.mjs +191 -0
- package/CHANGELOG.md +110 -0
- package/Readme.md +74 -27
- package/dist/canvas/index.js +82 -82
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +944 -164
- package/dist/types/json-render/catalog.d.ts +195 -20
- package/dist/types/json-render/charts/components.d.ts +17 -0
- package/dist/types/json-render/charts/definitions.d.ts +13 -1
- package/dist/types/json-render/charts/tufte-components.d.ts +65 -0
- package/dist/types/json-render/charts/tufte-definitions.d.ts +164 -0
- package/dist/types/json-render/directives.d.ts +33 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +32 -1
- package/dist/types/mcp/canvas-access.d.ts +62 -0
- package/dist/types/server/ax-state.d.ts +170 -0
- package/dist/types/server/canvas-db.d.ts +17 -1
- package/dist/types/server/canvas-operations.d.ts +53 -0
- package/dist/types/server/canvas-schema.d.ts +5 -1
- package/dist/types/server/canvas-state.d.ts +95 -4
- package/dist/types/server/index.d.ts +120 -3
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +42 -0
- package/docs/http-api.md +64 -0
- package/docs/mcp.md +23 -5
- package/docs/node-types.md +1 -1
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/docs/sdk.md +23 -5
- package/package.json +10 -7
- package/skills/control-session-orchestrator/SKILL.md +359 -0
- package/skills/control-session-orchestrator/evals/evals.json +75 -0
- package/skills/data-analysis/SKILL.md +6 -0
- package/skills/pmx-canvas/SKILL.md +50 -4
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +6 -0
- package/skills/tufte-viz/SKILL.md +157 -0
- package/skills/tufte-viz/references/analytical-design.md +217 -0
- package/skills/tufte-viz/references/tufte-principles.md +147 -0
- package/src/cli/agent.ts +302 -3
- package/src/cli/index.ts +2 -1
- package/src/client/nodes/ExtAppFrame.tsx +48 -1
- package/src/client/nodes/McpAppNode.tsx +6 -2
- package/src/json-render/catalog.ts +22 -1
- package/src/json-render/charts/components.tsx +127 -15
- package/src/json-render/charts/definitions.ts +19 -2
- package/src/json-render/charts/extra-components.tsx +5 -4
- package/src/json-render/charts/tufte-components.tsx +395 -0
- package/src/json-render/charts/tufte-definitions.ts +128 -0
- package/src/json-render/directives.ts +64 -0
- package/src/json-render/renderer/index.css +107 -1
- package/src/json-render/renderer/index.tsx +33 -0
- package/src/json-render/server.ts +275 -5
- package/src/mcp/canvas-access.ts +264 -1
- package/src/mcp/server.ts +498 -9
- package/src/server/ax-context.ts +8 -3
- package/src/server/ax-state.ts +447 -0
- package/src/server/canvas-db.ts +184 -1
- package/src/server/canvas-operations.ts +123 -2
- package/src/server/canvas-schema.ts +27 -3
- package/src/server/canvas-state.ts +349 -2
- package/src/server/index.ts +259 -7
- package/src/server/mutation-history.ts +6 -0
- package/src/server/server.ts +442 -5
- package/src/server/web-artifacts.ts +31 -5
package/src/server/server.ts
CHANGED
|
@@ -77,7 +77,15 @@ import {
|
|
|
77
77
|
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
78
78
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
79
79
|
import { buildCanvasAxContext } from './ax-context.js';
|
|
80
|
-
import
|
|
80
|
+
import { isAxEventKind, isAxEvidenceKind } from './ax-state.js';
|
|
81
|
+
import type {
|
|
82
|
+
PmxAxReviewAnchorType,
|
|
83
|
+
PmxAxReviewKind,
|
|
84
|
+
PmxAxReviewRegion,
|
|
85
|
+
PmxAxReviewSeverity,
|
|
86
|
+
PmxAxReviewStatus,
|
|
87
|
+
PmxAxSource,
|
|
88
|
+
} from './ax-state.js';
|
|
81
89
|
import { normalizeCanvasTheme, type CanvasTheme } from './canvas-db.js';
|
|
82
90
|
import { validateLocalImageFile } from './image-source.js';
|
|
83
91
|
import {
|
|
@@ -85,13 +93,17 @@ import {
|
|
|
85
93
|
addCanvasEdge,
|
|
86
94
|
MARKDOWN_NODE_DEFAULT_SIZE,
|
|
87
95
|
MCP_APP_NODE_DEFAULT_SIZE,
|
|
96
|
+
IMAGE_NODE_DEFAULT_SIZE,
|
|
97
|
+
LEDGER_NODE_DEFAULT_SIZE,
|
|
88
98
|
applyCanvasNodeUpdates,
|
|
99
|
+
appendCanvasJsonRenderStream,
|
|
89
100
|
buildStructuredNodeUpdate,
|
|
90
101
|
arrangeCanvasNodes,
|
|
91
102
|
clearCanvas,
|
|
92
103
|
createCanvasGraphNode,
|
|
93
104
|
createCanvasGroup,
|
|
94
105
|
createCanvasJsonRenderNode,
|
|
106
|
+
createCanvasStreamingJsonRenderNode,
|
|
95
107
|
deleteCanvasSnapshot,
|
|
96
108
|
executeCanvasBatch,
|
|
97
109
|
fitCanvasView,
|
|
@@ -101,6 +113,7 @@ import {
|
|
|
101
113
|
refreshCanvasWebpageNode,
|
|
102
114
|
removeCanvasNode,
|
|
103
115
|
removeCanvasEdge,
|
|
116
|
+
resolveHtmlContent,
|
|
104
117
|
restoreCanvasSnapshot,
|
|
105
118
|
saveCanvasSnapshot,
|
|
106
119
|
scheduleCodeGraphRecompute,
|
|
@@ -1495,7 +1508,13 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
|
|
|
1495
1508
|
if (!src || src.startsWith('data:') || src.startsWith('http')) {
|
|
1496
1509
|
return responseText('Not a file-based image', 400);
|
|
1497
1510
|
}
|
|
1498
|
-
|
|
1511
|
+
// Contain the file read to the active workspace. `src` comes from node data,
|
|
1512
|
+
// which any unauthenticated local caller can set — without this guard the
|
|
1513
|
+
// image route serves arbitrary host files (e.g. ../../etc/passwd).
|
|
1514
|
+
const safePath = resolveWorkspaceArtifactPath(src);
|
|
1515
|
+
if (!safePath) {
|
|
1516
|
+
return responseText('Image path is outside the workspace', 403);
|
|
1517
|
+
}
|
|
1499
1518
|
if (!existsSync(safePath)) {
|
|
1500
1519
|
return responseText('Image file not found', 404);
|
|
1501
1520
|
}
|
|
@@ -1656,7 +1675,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1656
1675
|
const htmlMergedData = type === 'html'
|
|
1657
1676
|
? {
|
|
1658
1677
|
...(extraData ?? {}),
|
|
1659
|
-
...(typeof body.html === 'string' ? { html: body.html } : {}),
|
|
1678
|
+
...(typeof body.html === 'string' ? { html: resolveHtmlContent(body.html) } : {}),
|
|
1660
1679
|
...(typeof body.summary === 'string' ? { summary: body.summary } : {}),
|
|
1661
1680
|
...(typeof body.agentSummary === 'string' ? { agentSummary: body.agentSummary } : {}),
|
|
1662
1681
|
...(typeof body.description === 'string' ? { description: body.description } : {}),
|
|
@@ -1688,14 +1707,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
|
1688
1707
|
? MARKDOWN_NODE_DEFAULT_SIZE.width
|
|
1689
1708
|
: type === 'mcp-app'
|
|
1690
1709
|
? MCP_APP_NODE_DEFAULT_SIZE.width
|
|
1691
|
-
:
|
|
1710
|
+
: type === 'image'
|
|
1711
|
+
? IMAGE_NODE_DEFAULT_SIZE.width
|
|
1712
|
+
: type === 'ledger'
|
|
1713
|
+
? LEDGER_NODE_DEFAULT_SIZE.width
|
|
1714
|
+
: 360,
|
|
1692
1715
|
defaultHeight: type === 'html'
|
|
1693
1716
|
? 640
|
|
1694
1717
|
: type === 'markdown'
|
|
1695
1718
|
? MARKDOWN_NODE_DEFAULT_SIZE.height
|
|
1696
1719
|
: type === 'mcp-app'
|
|
1697
1720
|
? MCP_APP_NODE_DEFAULT_SIZE.height
|
|
1698
|
-
:
|
|
1721
|
+
: type === 'image'
|
|
1722
|
+
? IMAGE_NODE_DEFAULT_SIZE.height
|
|
1723
|
+
: type === 'ledger'
|
|
1724
|
+
? LEDGER_NODE_DEFAULT_SIZE.height
|
|
1725
|
+
: 200,
|
|
1699
1726
|
fileMode: 'auto',
|
|
1700
1727
|
});
|
|
1701
1728
|
} catch (error) {
|
|
@@ -2061,6 +2088,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
|
2061
2088
|
...(typeof body.outputPath === 'string'
|
|
2062
2089
|
? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
|
|
2063
2090
|
: {}),
|
|
2091
|
+
// Script-path overrides are honored only when contained inside the
|
|
2092
|
+
// workspace (enforced by resolveTrustedScriptPath in
|
|
2093
|
+
// executeWebArtifactBuild), so they cannot point at an arbitrary host
|
|
2094
|
+
// script for bash execution.
|
|
2064
2095
|
...(typeof body.initScriptPath === 'string'
|
|
2065
2096
|
? { initScriptPath: body.initScriptPath }
|
|
2066
2097
|
: {}),
|
|
@@ -2214,6 +2245,34 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
|
|
|
2214
2245
|
}
|
|
2215
2246
|
}
|
|
2216
2247
|
|
|
2248
|
+
async function handleJsonRenderStream(req: Request): Promise<Response> {
|
|
2249
|
+
const body = await readJson(req);
|
|
2250
|
+
const patches = Array.isArray(body.patches) ? body.patches : [];
|
|
2251
|
+
const done = body.done === true;
|
|
2252
|
+
const geometry = resolveCreateGeometry(body);
|
|
2253
|
+
try {
|
|
2254
|
+
let nodeId = typeof body.nodeId === 'string' && body.nodeId ? body.nodeId : undefined;
|
|
2255
|
+
let url = '';
|
|
2256
|
+
if (!nodeId) {
|
|
2257
|
+
const created = createCanvasStreamingJsonRenderNode({
|
|
2258
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
2259
|
+
...(body.strictSize === true ? { strictSize: true } : {}),
|
|
2260
|
+
...geometry,
|
|
2261
|
+
});
|
|
2262
|
+
nodeId = created.id;
|
|
2263
|
+
url = created.url;
|
|
2264
|
+
}
|
|
2265
|
+
const result = appendCanvasJsonRenderStream(nodeId, patches, done);
|
|
2266
|
+
if (!result.ok) return responseJson({ ok: false, error: result.error }, 400);
|
|
2267
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
2268
|
+
const node = canvasState.getNode(nodeId);
|
|
2269
|
+
return responseJson({ id: nodeId, url: url || String(node?.data.url ?? ''), ...result });
|
|
2270
|
+
} catch (error) {
|
|
2271
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2272
|
+
return responseJson({ ok: false, error: message }, 400);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2217
2276
|
async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
2218
2277
|
const body = await readJson(req);
|
|
2219
2278
|
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
|
|
@@ -2242,6 +2301,16 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
|
2242
2301
|
const nodeHeight = pickPositiveNumber(body, 'nodeHeight') ?? (size ? pickPositiveNumber(size, 'height') : undefined);
|
|
2243
2302
|
const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
|
|
2244
2303
|
const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
|
|
2304
|
+
const colorBy =
|
|
2305
|
+
body.colorBy === 'series' || body.colorBy === 'category' || body.colorBy === 'value' || body.colorBy === 'none'
|
|
2306
|
+
? body.colorBy
|
|
2307
|
+
: undefined;
|
|
2308
|
+
const highlight =
|
|
2309
|
+
typeof body.highlight === 'number' || body.highlight === 'max' || body.highlight === 'min' || body.highlight === null
|
|
2310
|
+
? body.highlight
|
|
2311
|
+
: undefined;
|
|
2312
|
+
const sort =
|
|
2313
|
+
body.sort === 'asc' || body.sort === 'desc' || body.sort === 'none' ? body.sort : undefined;
|
|
2245
2314
|
const result = createCanvasGraphNode({
|
|
2246
2315
|
title,
|
|
2247
2316
|
graphType,
|
|
@@ -2258,8 +2327,23 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
|
2258
2327
|
...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
|
|
2259
2328
|
...(aggregate ? { aggregate } : {}),
|
|
2260
2329
|
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
2330
|
+
...(colorBy ? { colorBy } : {}),
|
|
2331
|
+
...(highlight !== undefined ? { highlight } : {}),
|
|
2261
2332
|
...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
|
|
2262
2333
|
...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
|
|
2334
|
+
...(typeof body.labelKey === 'string' ? { labelKey: body.labelKey } : {}),
|
|
2335
|
+
...(typeof body.targetKey === 'string' ? { targetKey: body.targetKey } : {}),
|
|
2336
|
+
...(typeof body.rangesKey === 'string' ? { rangesKey: body.rangesKey } : {}),
|
|
2337
|
+
...(typeof body.beforeKey === 'string' ? { beforeKey: body.beforeKey } : {}),
|
|
2338
|
+
...(typeof body.afterKey === 'string' ? { afterKey: body.afterKey } : {}),
|
|
2339
|
+
...(typeof body.beforeLabel === 'string' ? { beforeLabel: body.beforeLabel } : {}),
|
|
2340
|
+
...(typeof body.afterLabel === 'string' ? { afterLabel: body.afterLabel } : {}),
|
|
2341
|
+
...(sort ? { sort } : {}),
|
|
2342
|
+
...(typeof body.fill === 'boolean' ? { fill: body.fill } : {}),
|
|
2343
|
+
...(typeof body.showEndDot === 'boolean' ? { showEndDot: body.showEndDot } : {}),
|
|
2344
|
+
...(typeof body.showMinMax === 'boolean' ? { showMinMax: body.showMinMax } : {}),
|
|
2345
|
+
...(typeof body.showValue === 'boolean' ? { showValue: body.showValue } : {}),
|
|
2346
|
+
...(typeof body.colorByDirection === 'boolean' ? { colorByDirection: body.colorByDirection } : {}),
|
|
2263
2347
|
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
2264
2348
|
...(showLegend !== undefined ? { showLegend } : {}),
|
|
2265
2349
|
...(showLabels !== undefined ? { showLabels } : {}),
|
|
@@ -2334,11 +2418,17 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
|
2334
2418
|
? themeValue
|
|
2335
2419
|
: undefined;
|
|
2336
2420
|
const title = (node.data.title as string) || node.id;
|
|
2421
|
+
// Devtools panel is double-gated: the operator must opt in via the env flag
|
|
2422
|
+
// AND the request must carry ?devtools=1. Off by default in all normal runs.
|
|
2423
|
+
const devtoolsEnabled =
|
|
2424
|
+
process.env.PMX_CANVAS_JSON_RENDER_DEVTOOLS === '1' &&
|
|
2425
|
+
url.searchParams.get('devtools') === '1';
|
|
2337
2426
|
const html = await buildJsonRenderViewerHtml({
|
|
2338
2427
|
title,
|
|
2339
2428
|
spec,
|
|
2340
2429
|
...(theme ? { theme } : {}),
|
|
2341
2430
|
...(url.searchParams.get('display') === 'expanded' ? { display: 'expanded' as const } : {}),
|
|
2431
|
+
...(devtoolsEnabled ? { devtools: true } : {}),
|
|
2342
2432
|
});
|
|
2343
2433
|
return new Response(html, {
|
|
2344
2434
|
headers: {
|
|
@@ -3653,6 +3743,284 @@ async function handleAxStatePatch(req: Request): Promise<Response> {
|
|
|
3653
3743
|
return responseJson({ ok: true, state: canvasState.getAxState() });
|
|
3654
3744
|
}
|
|
3655
3745
|
|
|
3746
|
+
async function handleAxEventAdd(req: Request): Promise<Response> {
|
|
3747
|
+
const body = await readJson(req);
|
|
3748
|
+
if (!isAxEventKind(body.kind) || typeof body.summary !== 'string') {
|
|
3749
|
+
return responseJson({ ok: false, error: 'event requires kind and summary.' }, 400);
|
|
3750
|
+
}
|
|
3751
|
+
const event = canvasState.recordAxEvent(
|
|
3752
|
+
{
|
|
3753
|
+
kind: body.kind,
|
|
3754
|
+
summary: body.summary,
|
|
3755
|
+
detail: typeof body.detail === 'string' ? body.detail : null,
|
|
3756
|
+
nodeIds: normalizeAxNodeIds(body.nodeIds),
|
|
3757
|
+
data: isRecord(body.data) ? body.data : null,
|
|
3758
|
+
},
|
|
3759
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3760
|
+
);
|
|
3761
|
+
broadcastWorkbenchEvent('ax-event-created', {
|
|
3762
|
+
event,
|
|
3763
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3764
|
+
timestamp: new Date().toISOString(),
|
|
3765
|
+
});
|
|
3766
|
+
return responseJson({ ok: true, event });
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
async function handleAxSteer(req: Request): Promise<Response> {
|
|
3770
|
+
const body = await readJson(req);
|
|
3771
|
+
if (typeof body.message !== 'string' || !body.message.trim()) {
|
|
3772
|
+
return responseJson({ ok: false, error: 'steer requires a non-empty message.' }, 400);
|
|
3773
|
+
}
|
|
3774
|
+
const steering = canvasState.recordSteeringMessage(body.message, {
|
|
3775
|
+
source: normalizeAxSource(body.source, 'api'),
|
|
3776
|
+
});
|
|
3777
|
+
broadcastWorkbenchEvent('ax-event-created', {
|
|
3778
|
+
steering,
|
|
3779
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3780
|
+
timestamp: new Date().toISOString(),
|
|
3781
|
+
});
|
|
3782
|
+
return responseJson({ ok: true, steering });
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
function handleAxTimelineGet(url: URL): Response {
|
|
3786
|
+
const limit = Number(url.searchParams.get('limit') ?? '');
|
|
3787
|
+
return responseJson({
|
|
3788
|
+
ok: true,
|
|
3789
|
+
...canvasState.getAxTimeline(Number.isFinite(limit) && limit > 0 ? { limit } : {}),
|
|
3790
|
+
});
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
const AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked', 'done', 'cancelled']);
|
|
3794
|
+
|
|
3795
|
+
function normalizeAxWorkItemStatus(value: unknown): 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled' | undefined {
|
|
3796
|
+
return typeof value === 'string' && AX_WORK_STATUSES.has(value)
|
|
3797
|
+
? value as 'todo' | 'in-progress' | 'blocked' | 'done' | 'cancelled'
|
|
3798
|
+
: undefined;
|
|
3799
|
+
}
|
|
3800
|
+
|
|
3801
|
+
function handleAxWorkList(): Response {
|
|
3802
|
+
return responseJson({ ok: true, workItems: canvasState.getWorkItems() });
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
async function handleAxWorkAdd(req: Request): Promise<Response> {
|
|
3806
|
+
const body = await readJson(req);
|
|
3807
|
+
if (typeof body.title !== 'string' || !body.title.trim()) {
|
|
3808
|
+
return responseJson({ ok: false, error: 'work item requires a title.' }, 400);
|
|
3809
|
+
}
|
|
3810
|
+
const status = normalizeAxWorkItemStatus(body.status);
|
|
3811
|
+
const workItem = canvasState.addWorkItem(
|
|
3812
|
+
{
|
|
3813
|
+
title: body.title,
|
|
3814
|
+
...(status ? { status } : {}),
|
|
3815
|
+
...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
|
|
3816
|
+
...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
|
|
3817
|
+
},
|
|
3818
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3819
|
+
);
|
|
3820
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3821
|
+
workItem,
|
|
3822
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3823
|
+
timestamp: new Date().toISOString(),
|
|
3824
|
+
});
|
|
3825
|
+
return responseJson({ ok: true, workItem });
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
async function handleAxWorkUpdate(req: Request, id: string): Promise<Response> {
|
|
3829
|
+
const body = await readJson(req);
|
|
3830
|
+
const status = normalizeAxWorkItemStatus(body.status);
|
|
3831
|
+
const workItem = canvasState.updateWorkItem(
|
|
3832
|
+
id,
|
|
3833
|
+
{
|
|
3834
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
3835
|
+
...(status ? { status } : {}),
|
|
3836
|
+
...(typeof body.detail === 'string' || body.detail === null ? { detail: body.detail as string | null } : {}),
|
|
3837
|
+
...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
|
|
3838
|
+
},
|
|
3839
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3840
|
+
);
|
|
3841
|
+
if (!workItem) return responseJson({ ok: false, error: 'work item not found.' }, 404);
|
|
3842
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3843
|
+
workItem,
|
|
3844
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3845
|
+
timestamp: new Date().toISOString(),
|
|
3846
|
+
});
|
|
3847
|
+
return responseJson({ ok: true, workItem });
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
function handleAxApprovalList(): Response {
|
|
3851
|
+
return responseJson({ ok: true, approvalGates: canvasState.getApprovalGates() });
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
async function handleAxApprovalRequest(req: Request): Promise<Response> {
|
|
3855
|
+
const body = await readJson(req);
|
|
3856
|
+
if (typeof body.title !== 'string' || !body.title.trim()) {
|
|
3857
|
+
return responseJson({ ok: false, error: 'approval request requires a title.' }, 400);
|
|
3858
|
+
}
|
|
3859
|
+
const approvalGate = canvasState.requestApproval(
|
|
3860
|
+
{
|
|
3861
|
+
title: body.title,
|
|
3862
|
+
...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
|
|
3863
|
+
...(typeof body.action === 'string' ? { action: body.action } : {}),
|
|
3864
|
+
...(Array.isArray(body.nodeIds) ? { nodeIds: normalizeAxNodeIds(body.nodeIds) } : {}),
|
|
3865
|
+
},
|
|
3866
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3867
|
+
);
|
|
3868
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3869
|
+
approvalGate,
|
|
3870
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3871
|
+
timestamp: new Date().toISOString(),
|
|
3872
|
+
});
|
|
3873
|
+
return responseJson({ ok: true, approvalGate });
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
async function handleAxApprovalResolve(req: Request, id: string): Promise<Response> {
|
|
3877
|
+
const body = await readJson(req);
|
|
3878
|
+
if (body.decision !== 'approved' && body.decision !== 'rejected') {
|
|
3879
|
+
return responseJson({ ok: false, error: 'resolve requires decision approved or rejected.' }, 400);
|
|
3880
|
+
}
|
|
3881
|
+
const approvalGate = canvasState.resolveApproval(
|
|
3882
|
+
id,
|
|
3883
|
+
body.decision,
|
|
3884
|
+
{
|
|
3885
|
+
...(typeof body.resolution === 'string' ? { resolution: body.resolution } : {}),
|
|
3886
|
+
source: normalizeAxSource(body.source, 'api'),
|
|
3887
|
+
},
|
|
3888
|
+
);
|
|
3889
|
+
if (!approvalGate) return responseJson({ ok: false, error: 'approval gate not found or already resolved.' }, 404);
|
|
3890
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3891
|
+
approvalGate,
|
|
3892
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3893
|
+
timestamp: new Date().toISOString(),
|
|
3894
|
+
});
|
|
3895
|
+
return responseJson({ ok: true, approvalGate });
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
async function handleAxEvidenceAdd(req: Request): Promise<Response> {
|
|
3899
|
+
const body = await readJson(req);
|
|
3900
|
+
if (!isAxEvidenceKind(body.kind) || typeof body.title !== 'string' || !body.title.trim()) {
|
|
3901
|
+
return responseJson({ ok: false, error: 'evidence requires kind and title.' }, 400);
|
|
3902
|
+
}
|
|
3903
|
+
const evidence = canvasState.addEvidence(
|
|
3904
|
+
{
|
|
3905
|
+
kind: body.kind,
|
|
3906
|
+
title: body.title,
|
|
3907
|
+
body: typeof body.body === 'string' ? body.body : null,
|
|
3908
|
+
ref: typeof body.ref === 'string' ? body.ref : null,
|
|
3909
|
+
nodeIds: normalizeAxNodeIds(body.nodeIds),
|
|
3910
|
+
data: isRecord(body.data) ? body.data : null,
|
|
3911
|
+
},
|
|
3912
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3913
|
+
);
|
|
3914
|
+
broadcastWorkbenchEvent('ax-event-created', {
|
|
3915
|
+
evidence,
|
|
3916
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3917
|
+
timestamp: new Date().toISOString(),
|
|
3918
|
+
});
|
|
3919
|
+
return responseJson({ ok: true, evidence });
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
const AX_REVIEW_KINDS = new Set(['comment', 'finding']);
|
|
3923
|
+
const AX_REVIEW_SEVERITIES = new Set(['info', 'warning', 'error']);
|
|
3924
|
+
const AX_REVIEW_STATUSES = new Set(['open', 'resolved', 'dismissed']);
|
|
3925
|
+
const AX_REVIEW_ANCHORS = new Set(['node', 'file', 'region']);
|
|
3926
|
+
|
|
3927
|
+
function normalizeAxReviewKind(value: unknown): PmxAxReviewKind | undefined {
|
|
3928
|
+
return typeof value === 'string' && AX_REVIEW_KINDS.has(value) ? value as PmxAxReviewKind : undefined;
|
|
3929
|
+
}
|
|
3930
|
+
function normalizeAxReviewSeverity(value: unknown): PmxAxReviewSeverity | undefined {
|
|
3931
|
+
return typeof value === 'string' && AX_REVIEW_SEVERITIES.has(value) ? value as PmxAxReviewSeverity : undefined;
|
|
3932
|
+
}
|
|
3933
|
+
function normalizeAxReviewStatus(value: unknown): PmxAxReviewStatus | undefined {
|
|
3934
|
+
return typeof value === 'string' && AX_REVIEW_STATUSES.has(value) ? value as PmxAxReviewStatus : undefined;
|
|
3935
|
+
}
|
|
3936
|
+
function normalizeAxReviewAnchor(value: unknown): PmxAxReviewAnchorType | undefined {
|
|
3937
|
+
return typeof value === 'string' && AX_REVIEW_ANCHORS.has(value) ? value as PmxAxReviewAnchorType : undefined;
|
|
3938
|
+
}
|
|
3939
|
+
function normalizeAxReviewRegion(value: unknown): PmxAxReviewRegion | undefined {
|
|
3940
|
+
if (!isRecord(value)) return undefined;
|
|
3941
|
+
return {
|
|
3942
|
+
...(typeof value.line === 'number' ? { line: value.line } : {}),
|
|
3943
|
+
...(typeof value.endLine === 'number' ? { endLine: value.endLine } : {}),
|
|
3944
|
+
...(typeof value.label === 'string' ? { label: value.label } : {}),
|
|
3945
|
+
};
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
function handleAxReviewList(): Response {
|
|
3949
|
+
return responseJson({ ok: true, reviewAnnotations: canvasState.getReviewAnnotations() });
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
async function handleAxReviewAdd(req: Request): Promise<Response> {
|
|
3953
|
+
const body = await readJson(req);
|
|
3954
|
+
if (typeof body.body !== 'string' || !body.body.trim()) {
|
|
3955
|
+
return responseJson({ ok: false, error: 'review annotation requires a body.' }, 400);
|
|
3956
|
+
}
|
|
3957
|
+
const kind = normalizeAxReviewKind(body.kind);
|
|
3958
|
+
const severity = normalizeAxReviewSeverity(body.severity);
|
|
3959
|
+
const anchorType = normalizeAxReviewAnchor(body.anchorType);
|
|
3960
|
+
const region = normalizeAxReviewRegion(body.region);
|
|
3961
|
+
const reviewAnnotation = canvasState.addReviewAnnotation(
|
|
3962
|
+
{
|
|
3963
|
+
body: body.body,
|
|
3964
|
+
...(kind ? { kind } : {}),
|
|
3965
|
+
...(severity ? { severity } : {}),
|
|
3966
|
+
...(anchorType ? { anchorType } : {}),
|
|
3967
|
+
...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
|
|
3968
|
+
...(typeof body.file === 'string' ? { file: body.file } : {}),
|
|
3969
|
+
...(region ? { region } : {}),
|
|
3970
|
+
...(typeof body.author === 'string' ? { author: body.author } : {}),
|
|
3971
|
+
},
|
|
3972
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3973
|
+
);
|
|
3974
|
+
if (!reviewAnnotation) {
|
|
3975
|
+
return responseJson({ ok: false, error: 'node-anchored review annotation requires a nodeId that exists on the canvas.' }, 400);
|
|
3976
|
+
}
|
|
3977
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
3978
|
+
reviewAnnotation,
|
|
3979
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3980
|
+
timestamp: new Date().toISOString(),
|
|
3981
|
+
});
|
|
3982
|
+
return responseJson({ ok: true, reviewAnnotation });
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
async function handleAxReviewUpdate(req: Request, id: string): Promise<Response> {
|
|
3986
|
+
const body = await readJson(req);
|
|
3987
|
+
const status = normalizeAxReviewStatus(body.status);
|
|
3988
|
+
const severity = normalizeAxReviewSeverity(body.severity);
|
|
3989
|
+
const kind = normalizeAxReviewKind(body.kind);
|
|
3990
|
+
const reviewAnnotation = canvasState.updateReviewAnnotation(
|
|
3991
|
+
id,
|
|
3992
|
+
{
|
|
3993
|
+
...(typeof body.body === 'string' ? { body: body.body } : {}),
|
|
3994
|
+
...(status ? { status } : {}),
|
|
3995
|
+
...(severity ? { severity } : {}),
|
|
3996
|
+
...(kind ? { kind } : {}),
|
|
3997
|
+
},
|
|
3998
|
+
{ source: normalizeAxSource(body.source, 'api') },
|
|
3999
|
+
);
|
|
4000
|
+
if (!reviewAnnotation) return responseJson({ ok: false, error: 'review annotation not found.' }, 404);
|
|
4001
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
4002
|
+
reviewAnnotation,
|
|
4003
|
+
sessionId: primaryWorkbenchSessionId,
|
|
4004
|
+
timestamp: new Date().toISOString(),
|
|
4005
|
+
});
|
|
4006
|
+
return responseJson({ ok: true, reviewAnnotation });
|
|
4007
|
+
}
|
|
4008
|
+
|
|
4009
|
+
function handleAxHostCapabilityGet(): Response {
|
|
4010
|
+
return responseJson({ ok: true, host: canvasState.getHostCapability() });
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
async function handleAxHostCapabilityReport(req: Request): Promise<Response> {
|
|
4014
|
+
const body = await readJson(req);
|
|
4015
|
+
const host = canvasState.setHostCapability(body, { source: normalizeAxSource(body.source, 'api') });
|
|
4016
|
+
broadcastWorkbenchEvent('ax-state-changed', {
|
|
4017
|
+
host,
|
|
4018
|
+
sessionId: primaryWorkbenchSessionId,
|
|
4019
|
+
timestamp: new Date().toISOString(),
|
|
4020
|
+
});
|
|
4021
|
+
return responseJson({ ok: true, host });
|
|
4022
|
+
}
|
|
4023
|
+
|
|
3656
4024
|
// ── Port resolution ───────────────────────────────────────────
|
|
3657
4025
|
|
|
3658
4026
|
function buildPortCandidates(preferredPort: number): number[] {
|
|
@@ -4641,6 +5009,71 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4641
5009
|
return handleAxFocusUpdate(req);
|
|
4642
5010
|
}
|
|
4643
5011
|
|
|
5012
|
+
if (url.pathname === '/api/canvas/ax/event' && req.method === 'POST') {
|
|
5013
|
+
return handleAxEventAdd(req);
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
if (url.pathname === '/api/canvas/ax/steer' && req.method === 'POST') {
|
|
5017
|
+
return handleAxSteer(req);
|
|
5018
|
+
}
|
|
5019
|
+
|
|
5020
|
+
if (url.pathname === '/api/canvas/ax/timeline' && req.method === 'GET') {
|
|
5021
|
+
return handleAxTimelineGet(url);
|
|
5022
|
+
}
|
|
5023
|
+
|
|
5024
|
+
if (url.pathname === '/api/canvas/ax/work' && req.method === 'GET') {
|
|
5025
|
+
return handleAxWorkList();
|
|
5026
|
+
}
|
|
5027
|
+
|
|
5028
|
+
if (url.pathname === '/api/canvas/ax/work' && req.method === 'POST') {
|
|
5029
|
+
return handleAxWorkAdd(req);
|
|
5030
|
+
}
|
|
5031
|
+
|
|
5032
|
+
if (url.pathname.startsWith('/api/canvas/ax/work/') && req.method === 'PATCH') {
|
|
5033
|
+
const workItemId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/work/'.length));
|
|
5034
|
+
return handleAxWorkUpdate(req, workItemId);
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
if (url.pathname === '/api/canvas/ax/approval' && req.method === 'GET') {
|
|
5038
|
+
return handleAxApprovalList();
|
|
5039
|
+
}
|
|
5040
|
+
|
|
5041
|
+
if (url.pathname === '/api/canvas/ax/approval' && req.method === 'POST') {
|
|
5042
|
+
return handleAxApprovalRequest(req);
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
if (url.pathname.startsWith('/api/canvas/ax/approval/') && url.pathname.endsWith('/resolve') && req.method === 'POST') {
|
|
5046
|
+
const approvalId = decodeURIComponent(
|
|
5047
|
+
url.pathname.slice('/api/canvas/ax/approval/'.length, -'/resolve'.length),
|
|
5048
|
+
);
|
|
5049
|
+
return handleAxApprovalResolve(req, approvalId);
|
|
5050
|
+
}
|
|
5051
|
+
|
|
5052
|
+
if (url.pathname === '/api/canvas/ax/evidence' && req.method === 'POST') {
|
|
5053
|
+
return handleAxEvidenceAdd(req);
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
if (url.pathname === '/api/canvas/ax/review' && req.method === 'GET') {
|
|
5057
|
+
return handleAxReviewList();
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
if (url.pathname === '/api/canvas/ax/review' && req.method === 'POST') {
|
|
5061
|
+
return handleAxReviewAdd(req);
|
|
5062
|
+
}
|
|
5063
|
+
|
|
5064
|
+
if (url.pathname.startsWith('/api/canvas/ax/review/') && req.method === 'PATCH') {
|
|
5065
|
+
const reviewId = decodeURIComponent(url.pathname.slice('/api/canvas/ax/review/'.length));
|
|
5066
|
+
return handleAxReviewUpdate(req, reviewId);
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'GET') {
|
|
5070
|
+
return handleAxHostCapabilityGet();
|
|
5071
|
+
}
|
|
5072
|
+
|
|
5073
|
+
if (url.pathname === '/api/canvas/ax/host-capability' && req.method === 'PUT') {
|
|
5074
|
+
return handleAxHostCapabilityReport(req);
|
|
5075
|
+
}
|
|
5076
|
+
|
|
4644
5077
|
// Spatial context API
|
|
4645
5078
|
if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
|
|
4646
5079
|
const layout = canvasState.getLayout();
|
|
@@ -4703,6 +5136,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
4703
5136
|
return handleCanvasAddJsonRender(req);
|
|
4704
5137
|
}
|
|
4705
5138
|
|
|
5139
|
+
if (url.pathname === '/api/canvas/json-render/stream' && req.method === 'POST') {
|
|
5140
|
+
return handleJsonRenderStream(req);
|
|
5141
|
+
}
|
|
5142
|
+
|
|
4706
5143
|
if (url.pathname === '/api/canvas/graph' && req.method === 'POST') {
|
|
4707
5144
|
return handleCanvasAddGraph(req);
|
|
4708
5145
|
}
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
readdirSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
readFileSync,
|
|
8
|
+
realpathSync,
|
|
8
9
|
statSync,
|
|
9
10
|
unlinkSync,
|
|
10
11
|
writeFileSync,
|
|
11
12
|
} from 'node:fs';
|
|
12
|
-
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
13
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
14
|
import { ensureArtifactsDir, getWorkspaceRoot } from './artifact-paths.js';
|
|
14
15
|
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
15
16
|
import { findOpenCanvasPosition } from './placement.js';
|
|
@@ -338,6 +339,33 @@ async function runProcess(
|
|
|
338
339
|
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
339
340
|
}
|
|
340
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the init/bundle script path. Without an override the trusted bundled
|
|
344
|
+
* resolver is used. An override (a test/debugging escape hatch exposed on every
|
|
345
|
+
* surface — CLI --init-script-path/--bundle-script-path, the MCP tool, the HTTP
|
|
346
|
+
* endpoint, and the SDK) is only honored when it resolves inside the active
|
|
347
|
+
* workspace root — otherwise it would let a caller exec an arbitrary host script
|
|
348
|
+
* via bash as the server user. This containment is the single chokepoint that
|
|
349
|
+
* makes forwarding the field safe across all of those surfaces.
|
|
350
|
+
*/
|
|
351
|
+
function resolveTrustedScriptPath(override: string | undefined, kind: 'init' | 'bundle'): string {
|
|
352
|
+
if (!override) return resolveWebArtifactScriptPath(kind);
|
|
353
|
+
const resolved = resolve(override);
|
|
354
|
+
// Compare real (symlink-resolved) paths on both sides so a legitimately
|
|
355
|
+
// contained override under a symlinked root (e.g. macOS /var -> /private/var,
|
|
356
|
+
// or a workspace that itself lives beneath a symlink) is not wrongly rejected.
|
|
357
|
+
// The candidate is realpath'd only when it exists; a non-existent escape path
|
|
358
|
+
// still fails the containment check below (or the existsSync guard at the call
|
|
359
|
+
// site). This keeps the guard strict — genuine escapes are still rejected.
|
|
360
|
+
const realRoot = realpathSync(currentWorkspaceRoot());
|
|
361
|
+
const realCandidate = existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
362
|
+
const workspaceRel = relative(realRoot, realCandidate);
|
|
363
|
+
if (workspaceRel === '..' || workspaceRel.startsWith(`..${sep}`) || isAbsolute(workspaceRel)) {
|
|
364
|
+
throw new Error(`Web-artifact ${kind} script override must resolve inside the workspace: ${override}`);
|
|
365
|
+
}
|
|
366
|
+
return resolved;
|
|
367
|
+
}
|
|
368
|
+
|
|
341
369
|
export function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string {
|
|
342
370
|
const scriptFile = kind === 'init' ? 'init-artifact.sh' : 'bundle-artifact.sh';
|
|
343
371
|
const candidates = [
|
|
@@ -459,10 +487,8 @@ export async function executeWebArtifactBuild(
|
|
|
459
487
|
const slug = slugify(input.title);
|
|
460
488
|
const projectPath = resolve(input.projectPath ?? join(artifactsDir, '.web-artifacts', slug));
|
|
461
489
|
const outputPath = resolve(input.outputPath ?? join(artifactsDir, `${slug}.html`));
|
|
462
|
-
const initScriptPath =
|
|
463
|
-
const bundleScriptPath =
|
|
464
|
-
input.bundleScriptPath ?? resolveWebArtifactScriptPath('bundle'),
|
|
465
|
-
);
|
|
490
|
+
const initScriptPath = resolveTrustedScriptPath(input.initScriptPath, 'init');
|
|
491
|
+
const bundleScriptPath = resolveTrustedScriptPath(input.bundleScriptPath, 'bundle');
|
|
466
492
|
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
467
493
|
|
|
468
494
|
if (!existsSync(initScriptPath)) {
|