pmx-canvas 0.4.6 → 0.4.8
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 +79 -0
- package/Readme.md +56 -33
- package/dist/canvas/global.css +101 -3
- package/dist/canvas/index.js +93 -89
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-db.d.ts +14 -0
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +8 -0
- package/dist/types/server/canvas-validation.d.ts +15 -21
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/RELEASE.md +48 -0
- package/docs/cli.md +19 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +17 -0
- package/docs/mcp.md +12 -0
- package/docs/node-types.md +45 -1
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +74 -9
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +40 -0
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/cli/shared.ts +34 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
- package/src/client/canvas/auto-fit.ts +10 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/sse-bridge.ts +4 -1
- package/src/client/theme/global.css +101 -3
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +299 -4
- package/src/server/canvas-db.ts +138 -32
- package/src/server/canvas-operations.ts +44 -17
- package/src/server/canvas-state.ts +20 -10
- package/src/server/canvas-validation.ts +50 -41
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/ops/viewport.ts +7 -1
- package/src/server/server.ts +106 -5
- package/src/server/web-artifacts.ts +109 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/node-sizes.ts +52 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -643,6 +643,10 @@ body,
|
|
|
643
643
|
overflow: hidden;
|
|
644
644
|
text-overflow: ellipsis;
|
|
645
645
|
flex: 1;
|
|
646
|
+
/* The name is the one thing that must survive a cramped title bar. Everything
|
|
647
|
+
else in the bar is flex-shrink: 0, so without a floor the title absorbed all
|
|
648
|
+
the pressure and collapsed to an ellipsis on narrow nodes. */
|
|
649
|
+
min-width: 9ch;
|
|
646
650
|
min-width: 0;
|
|
647
651
|
}
|
|
648
652
|
|
|
@@ -670,6 +674,13 @@ body,
|
|
|
670
674
|
}
|
|
671
675
|
|
|
672
676
|
.canvas-node .node-ax-status {
|
|
677
|
+
/* A long status ("in-progress") must not push the node's NAME out of the bar;
|
|
678
|
+
the pill is a glance-level cue, the name is the identity. */
|
|
679
|
+
max-width: 11ch;
|
|
680
|
+
overflow: hidden;
|
|
681
|
+
text-overflow: ellipsis;
|
|
682
|
+
white-space: nowrap;
|
|
683
|
+
flex-shrink: 1;
|
|
673
684
|
font-size: calc(9px * var(--node-chrome-scale, 1));
|
|
674
685
|
padding: calc(1px * var(--node-chrome-scale, 1)) calc(6px * var(--node-chrome-scale, 1));
|
|
675
686
|
border-radius: calc(4px * var(--node-chrome-scale, 1));
|
|
@@ -1846,10 +1857,11 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
1846
1857
|
border-radius: 999px;
|
|
1847
1858
|
}
|
|
1848
1859
|
|
|
1849
|
-
/* Edge labels
|
|
1860
|
+
/* Edge labels — --edge-chrome-scale is set on the edge <svg> so labels keep a
|
|
1861
|
+
constant screen size while zoomed out (see edgeChromeScale in EdgeLayer). */
|
|
1850
1862
|
.edge-label {
|
|
1851
1863
|
font-family: var(--font);
|
|
1852
|
-
font-size: 11px;
|
|
1864
|
+
font-size: calc(11px * var(--edge-chrome-scale, 1));
|
|
1853
1865
|
pointer-events: none;
|
|
1854
1866
|
user-select: none;
|
|
1855
1867
|
}
|
|
@@ -1857,7 +1869,7 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
1857
1869
|
.edge-label-bg {
|
|
1858
1870
|
fill: var(--c-panel-glass);
|
|
1859
1871
|
stroke: var(--c-line);
|
|
1860
|
-
stroke-width: 0.5;
|
|
1872
|
+
stroke-width: calc(0.5 * var(--edge-chrome-scale, 1));
|
|
1861
1873
|
}
|
|
1862
1874
|
|
|
1863
1875
|
/* Trace nodes — compact pills for agent tool/subagent visualization */
|
|
@@ -1963,6 +1975,92 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
1963
1975
|
color: var(--c-text-soft);
|
|
1964
1976
|
}
|
|
1965
1977
|
|
|
1978
|
+
/* File nodes — delimited-data tables and non-text placeholders */
|
|
1979
|
+
.file-table-shell {
|
|
1980
|
+
display: flex;
|
|
1981
|
+
flex-direction: column;
|
|
1982
|
+
height: 100%;
|
|
1983
|
+
min-height: 0;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
.file-node-notice {
|
|
1987
|
+
flex-shrink: 0;
|
|
1988
|
+
padding: 4px 10px;
|
|
1989
|
+
color: var(--c-muted);
|
|
1990
|
+
font-size: 10px;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/* Own scroll port so a wide CSV scrolls inside the node instead of blowing out
|
|
1994
|
+
the layout — and so the sticky header row has something to stick to. */
|
|
1995
|
+
.file-table-wrap {
|
|
1996
|
+
flex: 1;
|
|
1997
|
+
min-height: 0;
|
|
1998
|
+
overflow: auto;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/* The shell/wrap prefix is load-bearing: these rules must out-specify the markdown
|
|
2002
|
+
table styles (`.canvas-node .node-body table` / `th` / `td`), whose 0.5em
|
|
2003
|
+
margin and translucent header background break the sticky header. Borders are
|
|
2004
|
+
separate, not collapsed — collapsed borders belong to the table element, so
|
|
2005
|
+
the header leaks the rows scrolling beneath it. */
|
|
2006
|
+
.file-table-shell .file-table-wrap .file-table {
|
|
2007
|
+
margin: 0;
|
|
2008
|
+
border-collapse: separate;
|
|
2009
|
+
border-spacing: 0;
|
|
2010
|
+
width: max-content;
|
|
2011
|
+
min-width: 100%;
|
|
2012
|
+
font-family: var(--mono);
|
|
2013
|
+
color: var(--c-text);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
.file-table-shell .file-table-wrap .file-table th,
|
|
2017
|
+
.file-table-shell .file-table-wrap .file-table td {
|
|
2018
|
+
max-width: 320px;
|
|
2019
|
+
padding: 3px 8px;
|
|
2020
|
+
border: none;
|
|
2021
|
+
border-right: 1px solid var(--c-line);
|
|
2022
|
+
border-bottom: 1px solid var(--c-line);
|
|
2023
|
+
text-align: left;
|
|
2024
|
+
white-space: nowrap;
|
|
2025
|
+
overflow: hidden;
|
|
2026
|
+
text-overflow: ellipsis;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
.file-table-shell .file-table-wrap .file-table th {
|
|
2030
|
+
position: sticky;
|
|
2031
|
+
top: 0;
|
|
2032
|
+
z-index: 1;
|
|
2033
|
+
/* Opaque in every theme — rows must not bleed through while scrolling. */
|
|
2034
|
+
background: var(--c-panel);
|
|
2035
|
+
color: var(--c-text-soft);
|
|
2036
|
+
font-weight: 600;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
.file-table-shell .file-table-wrap .file-table tbody tr:nth-child(even) {
|
|
2040
|
+
background: var(--c-surface-subtle);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
.file-node-placeholder {
|
|
2044
|
+
display: flex;
|
|
2045
|
+
flex-direction: column;
|
|
2046
|
+
align-items: center;
|
|
2047
|
+
justify-content: center;
|
|
2048
|
+
gap: 4px;
|
|
2049
|
+
height: 100%;
|
|
2050
|
+
padding: 16px;
|
|
2051
|
+
color: var(--c-muted);
|
|
2052
|
+
text-align: center;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
.file-node-placeholder-name {
|
|
2056
|
+
color: var(--c-text-soft);
|
|
2057
|
+
word-break: break-all;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
.file-node-link {
|
|
2061
|
+
color: var(--c-accent);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1966
2064
|
/* Docked node — compact toolbar-flanking panel */
|
|
1967
2065
|
.docked-node {
|
|
1968
2066
|
min-width: 200px;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side bounded loop for materialized AX flows.
|
|
3
|
+
*
|
|
4
|
+
* A flow's loop lives on the CANVAS, not in a panel: the anchor step node carries
|
|
5
|
+
* `data.axFlow.loop` (see shared/ax-flow.ts), so it survives a browser refresh, an
|
|
6
|
+
* iframe reload, and a server restart — where a panel-driven loop dies with its
|
|
7
|
+
* document. The driver is `canvasState`'s work-item change hook: whenever a step's
|
|
8
|
+
* work item reaches `done`, the next step is opened; when the LAST step finishes,
|
|
9
|
+
* the next run starts — until the run cap is reached or a human presses Stop.
|
|
10
|
+
*
|
|
11
|
+
* Bounds (non-negotiable, enforced here rather than trusted from node data):
|
|
12
|
+
* - advances only while `loop.running` is true
|
|
13
|
+
* - stops at `maxRuns`, itself clamped to AX_FLOW_LOOP_HARD_CAP (20)
|
|
14
|
+
* - a `blocked`/`cancelled` step halts advancement (the loop waits, it does not
|
|
15
|
+
* skip); a step whose work item no longer exists stops the loop outright
|
|
16
|
+
* - re-entrancy: every write this module makes fires the same work-item change
|
|
17
|
+
* hook, so `advancing` drops the nested call. State is recomputed from scratch
|
|
18
|
+
* on each pass, so dropping nested calls loses nothing.
|
|
19
|
+
*/
|
|
20
|
+
import { AX_FLOW_LOOP_HARD_CAP, readAxFlow, type AxFlowLoopState, type AxFlowStamp } from '../shared/ax-flow.js';
|
|
21
|
+
import type { PmxAxWorkItem } from './ax-state.js';
|
|
22
|
+
import { emitCanvasLayoutUpdate } from './canvas-operations.js';
|
|
23
|
+
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
24
|
+
import { refreshWorkboardNodes } from './workboard.js';
|
|
25
|
+
|
|
26
|
+
/** Re-entrancy guard: true while an advance pass is writing. */
|
|
27
|
+
let advancing = false;
|
|
28
|
+
|
|
29
|
+
/** Persist a new loop state on the anchor node (durable: this is what Stop writes). */
|
|
30
|
+
function writeLoopState(node: CanvasNodeState, flow: AxFlowStamp, loop: AxFlowLoopState): void {
|
|
31
|
+
const current = canvasState.getNode(node.id);
|
|
32
|
+
if (!current) return;
|
|
33
|
+
canvasState.updateNode(node.id, { data: { ...current.data, axFlow: { ...flow, loop } } });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Advance one flow by at most one step (or one run boundary). Returns true when
|
|
38
|
+
* anything changed, so the caller knows whether to broadcast a layout update.
|
|
39
|
+
*/
|
|
40
|
+
function advanceFlow(node: CanvasNodeState, flow: AxFlowStamp, byId: Map<string, PmxAxWorkItem>): boolean {
|
|
41
|
+
const items = flow.steps.map((step) => byId.get(step.workItemId));
|
|
42
|
+
if (items.some((item) => !item)) {
|
|
43
|
+
// The flow was partially dismantled (work items cleared/deleted). Stop rather
|
|
44
|
+
// than keep a loop armed against state it can no longer drive.
|
|
45
|
+
writeLoopState(node, flow, { ...flow.loop, running: false });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const statuses = items as PmxAxWorkItem[];
|
|
49
|
+
// A blocked/cancelled step is a deliberate halt — hold position, stay armed.
|
|
50
|
+
if (statuses.some((item) => item.status === 'blocked' || item.status === 'cancelled')) return false;
|
|
51
|
+
|
|
52
|
+
const openIndex = statuses.findIndex((item) => item.status !== 'done');
|
|
53
|
+
if (openIndex >= 0) {
|
|
54
|
+
// Every earlier step is done by construction. Open this one exactly once:
|
|
55
|
+
// an already in-progress step is the loop's own previous advance.
|
|
56
|
+
if (statuses[openIndex].status !== 'todo') return false;
|
|
57
|
+
canvasState.updateWorkItem(statuses[openIndex].id, { status: 'in-progress' }, { source: 'system' });
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every step is done — one full run just completed.
|
|
62
|
+
const maxRuns = Math.min(flow.loop.maxRuns, AX_FLOW_LOOP_HARD_CAP);
|
|
63
|
+
const run = flow.loop.run + 1;
|
|
64
|
+
if (run >= maxRuns) {
|
|
65
|
+
// The cap is reached WITH this run recorded: no run `maxRuns + 1` is opened.
|
|
66
|
+
writeLoopState(node, flow, { running: false, run, maxRuns });
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
// Write the counter before re-opening the steps, so a crash mid-pass can never
|
|
70
|
+
// replay a run for free.
|
|
71
|
+
writeLoopState(node, flow, { running: true, run, maxRuns });
|
|
72
|
+
for (const step of flow.steps) {
|
|
73
|
+
canvasState.updateWorkItem(step.workItemId, { status: 'todo' }, { source: 'system' });
|
|
74
|
+
}
|
|
75
|
+
canvasState.updateWorkItem(flow.steps[0].workItemId, { status: 'in-progress' }, { source: 'system' });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Advance every running flow loop on the canvas. Safe to call on any work-item
|
|
81
|
+
* change: flows that are not running, not ready, or already advanced are no-ops.
|
|
82
|
+
*/
|
|
83
|
+
export function advanceAxFlowLoops(): void {
|
|
84
|
+
if (advancing) return;
|
|
85
|
+
advancing = true;
|
|
86
|
+
try {
|
|
87
|
+
const anchors = canvasState
|
|
88
|
+
.getLayout()
|
|
89
|
+
.nodes.map((node) => ({ node, flow: readAxFlow(node.data) }))
|
|
90
|
+
.filter((entry): entry is { node: CanvasNodeState; flow: AxFlowStamp } => entry.flow?.loop.running === true);
|
|
91
|
+
if (anchors.length === 0) return;
|
|
92
|
+
const byId = new Map(canvasState.getWorkItems().map((item) => [item.id, item]));
|
|
93
|
+
let changed = false;
|
|
94
|
+
// Suppressed: loop bookkeeping (status chips, the run counter) is automatic
|
|
95
|
+
// and must not fill undo history — same contract as the live workboard.
|
|
96
|
+
canvasState.withSuppressedRecording(() => {
|
|
97
|
+
for (const { node, flow } of anchors) {
|
|
98
|
+
if (advanceFlow(node, flow, byId)) changed = true;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
if (changed) emitCanvasLayoutUpdate();
|
|
102
|
+
} finally {
|
|
103
|
+
advancing = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// canvasState exposes ONE work-item listener slot, so this module owns the
|
|
108
|
+
// composition: the live workboard refresh first (unchanged behaviour), then the
|
|
109
|
+
// flow advance. Importing workboard also guarantees ordering — its own
|
|
110
|
+
// registration runs while this module is still evaluating, so this one wins.
|
|
111
|
+
canvasState.setWorkItemsChangedListener(() => {
|
|
112
|
+
refreshWorkboardNodes();
|
|
113
|
+
advanceAxFlowLoops();
|
|
114
|
+
});
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { z } from 'zod';
|
|
19
|
-
import type
|
|
19
|
+
import { clampAxFlowMaxRuns, type AxFlowStamp, type AxFlowStepStamp } from '../shared/ax-flow.js';
|
|
20
|
+
import { findOpenCanvasPosition, overlapsAny, type CanvasPlacementRect } from '../shared/placement.js';
|
|
21
|
+
import type { CanvasEdge, CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
20
22
|
import type { CanvasNodeType } from './canvas-provenance.js';
|
|
21
23
|
import type {
|
|
22
24
|
PmxAxApprovalGate,
|
|
@@ -54,6 +56,7 @@ export const AX_INTERACTION_TYPES = [
|
|
|
54
56
|
'ax.command.invoke',
|
|
55
57
|
'ax.elicitation.request',
|
|
56
58
|
'ax.mode.request',
|
|
59
|
+
'ax.flow.materialize',
|
|
57
60
|
] as const;
|
|
58
61
|
|
|
59
62
|
export type AxInteractionType = (typeof AX_INTERACTION_TYPES)[number];
|
|
@@ -86,9 +89,21 @@ function caps(
|
|
|
86
89
|
* a node can anchor AX state but only eligible types may EMIT interactions.
|
|
87
90
|
*/
|
|
88
91
|
export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabilities> = {
|
|
92
|
+
// `ax.work.update` is what the native step controls (Start/Done/Blocked) on a
|
|
93
|
+
// materialized flow step emit, and every step node is a `markdown` node. Two
|
|
94
|
+
// reasons this is not an escalation: it is no broader in kind than the
|
|
95
|
+
// `ax.work.create` / `ax.steer` this type already grants (creating work and
|
|
96
|
+
// steering the agent are strictly more powerful than flipping a status), and a
|
|
97
|
+
// markdown node has no sandboxed emit bridge — `window.PMX_AX` is injected only
|
|
98
|
+
// into `html` / `mcp-app` surfaces, so the only emitters here are the trusted
|
|
99
|
+
// native controls in the canvas app and direct local API calls, which can
|
|
100
|
+
// already PATCH a work item with no node capability at all.
|
|
101
|
+
// NB: markdown IS rendered as HTML into the parent document (see the trust
|
|
102
|
+
// boundary note in MarkdownNode.tsx — localhost, user-owned content), so the
|
|
103
|
+
// justification is "no sandboxed emitter", NOT "no scriptable surface".
|
|
89
104
|
markdown: caps(
|
|
90
105
|
true,
|
|
91
|
-
['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
106
|
+
['ax.steer', 'ax.work.create', 'ax.work.update', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
92
107
|
'notify-agent',
|
|
93
108
|
),
|
|
94
109
|
context: caps(
|
|
@@ -137,6 +152,11 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
|
|
|
137
152
|
'ax.mode.request',
|
|
138
153
|
'ax.command.invoke',
|
|
139
154
|
'ax.event.record',
|
|
155
|
+
// Only `html` gets the flow materializer: a panel is the one surface that
|
|
156
|
+
// authors a flow the human then wants ON the board. The output shape is
|
|
157
|
+
// server-owned (fixed markdown/flow/references geometry), so this grants no
|
|
158
|
+
// arbitrary node creation — see materializeFlow.
|
|
159
|
+
'ax.flow.materialize',
|
|
140
160
|
],
|
|
141
161
|
'notify-agent',
|
|
142
162
|
),
|
|
@@ -246,6 +266,23 @@ export interface AxInteractionInput {
|
|
|
246
266
|
metadata?: Record<string, unknown>;
|
|
247
267
|
}
|
|
248
268
|
|
|
269
|
+
// ── ax.flow.materialize bounds + fixed layout ──────────────────
|
|
270
|
+
/** Hard cap on materialized steps: one screen-band of nodes, not a node bomb. */
|
|
271
|
+
const FLOW_MAX_STEPS = 12;
|
|
272
|
+
const FLOW_TITLE_MAX = 120;
|
|
273
|
+
const FLOW_DETAIL_MAX = 2000;
|
|
274
|
+
const FLOW_MAX_LOOP_RUNS = 20;
|
|
275
|
+
/**
|
|
276
|
+
* Step node geometry. 360 is markdown's minimum create size, but it is too
|
|
277
|
+
* narrow for a step's NAME: the title bar also carries the type icon, the work
|
|
278
|
+
* status pill and the control cluster, so a title like "3. Regenerate + verify"
|
|
279
|
+
* ellipsised away at 100% zoom. Wide enough that the step reads at a glance.
|
|
280
|
+
*/
|
|
281
|
+
const FLOW_STEP_WIDTH = 460;
|
|
282
|
+
const FLOW_STEP_HEIGHT = 200;
|
|
283
|
+
const FLOW_STEP_GAP = 48;
|
|
284
|
+
const FLOW_STEP_COLUMNS = 4;
|
|
285
|
+
|
|
249
286
|
const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
250
287
|
'ax.event.record': z.object({
|
|
251
288
|
kind: z.enum(EVENT_KINDS),
|
|
@@ -311,6 +348,27 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
|
311
348
|
name: z.string().min(1),
|
|
312
349
|
args: z.record(z.string(), z.unknown()).optional(),
|
|
313
350
|
}),
|
|
351
|
+
// TEXT ONLY. The caller never supplies a node type, geometry, or node data —
|
|
352
|
+
// the server owns the materialized shape (see materializeFlow), which is what
|
|
353
|
+
// keeps this from being "a sandboxed panel can create arbitrary canvas nodes".
|
|
354
|
+
'ax.flow.materialize': z.object({
|
|
355
|
+
title: z.string().trim().max(FLOW_TITLE_MAX).optional(),
|
|
356
|
+
steps: z
|
|
357
|
+
.array(
|
|
358
|
+
z.object({
|
|
359
|
+
title: z.string().trim().min(1).max(FLOW_TITLE_MAX),
|
|
360
|
+
detail: z.string().trim().max(FLOW_DETAIL_MAX).optional(),
|
|
361
|
+
}),
|
|
362
|
+
)
|
|
363
|
+
.min(1)
|
|
364
|
+
.max(FLOW_MAX_STEPS),
|
|
365
|
+
loop: z
|
|
366
|
+
.object({
|
|
367
|
+
enabled: z.boolean().optional(),
|
|
368
|
+
maxRuns: z.number().int().min(1).max(FLOW_MAX_LOOP_RUNS).optional(),
|
|
369
|
+
})
|
|
370
|
+
.optional(),
|
|
371
|
+
}),
|
|
314
372
|
};
|
|
315
373
|
|
|
316
374
|
// ── Dispatch ───────────────────────────────────────────────────
|
|
@@ -321,6 +379,13 @@ const PAYLOAD_SCHEMAS: Record<string, z.ZodType> = {
|
|
|
321
379
|
*/
|
|
322
380
|
export interface AxInteractionManager {
|
|
323
381
|
getNode(id: string): CanvasNodeState | undefined;
|
|
382
|
+
/** Canvas read for `ax.flow.materialize` (placement obstacles + stale flow sweep). */
|
|
383
|
+
getLayout(): CanvasLayout;
|
|
384
|
+
addNode(node: CanvasNodeState): void;
|
|
385
|
+
updateNode(id: string, patch: Partial<CanvasNodeState>): void;
|
|
386
|
+
/** Also drops the node's edges — the flow's replace path relies on that. */
|
|
387
|
+
removeNode(id: string): void;
|
|
388
|
+
addEdge(edge: CanvasEdge): boolean;
|
|
324
389
|
recordAxEvent(
|
|
325
390
|
input: {
|
|
326
391
|
kind: PmxAxEventKind;
|
|
@@ -428,6 +493,227 @@ function accept(
|
|
|
428
493
|
};
|
|
429
494
|
}
|
|
430
495
|
|
|
496
|
+
/**
|
|
497
|
+
* Add a layout broadcast to a work-item result when the item is linked to nodes.
|
|
498
|
+
*
|
|
499
|
+
* A linked work item mirrors its status onto `data.axWorkStatus` of every node it
|
|
500
|
+
* names, so a work interaction IS a node-data change even though the op itself is
|
|
501
|
+
* layout-neutral. Without this the browser keeps rendering a stale status chip
|
|
502
|
+
* (the AX SSE frames carry AX state, not nodes) — the same reason materializeFlow
|
|
503
|
+
* emits one.
|
|
504
|
+
*/
|
|
505
|
+
function withLayoutBroadcast(
|
|
506
|
+
result: AxInteractionResult,
|
|
507
|
+
manager: AxInteractionManager,
|
|
508
|
+
workItems: PmxAxWorkItem[],
|
|
509
|
+
): AxInteractionResult {
|
|
510
|
+
if (!workItems.some((item) => item.nodeIds.length > 0)) return result;
|
|
511
|
+
result.events.push({ event: 'canvas-layout-update', payload: { layout: manager.getLayout() } });
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── ax.flow.materialize ────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
export interface AxFlowMaterializeInput {
|
|
518
|
+
title?: string;
|
|
519
|
+
steps: Array<{ title: string; detail?: string }>;
|
|
520
|
+
loop?: { enabled?: boolean; maxRuns?: number };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Shared tag on every node this source materialized — the unit of idempotent replace. */
|
|
524
|
+
function axFlowId(sourceNodeId: string): string {
|
|
525
|
+
return `axflow-${sourceNodeId}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Ids the source node recorded the last time it materialized. This manifest — not
|
|
530
|
+
* the `axFlowId` tag alone — is what the replace sweep deletes from: `axFlowId` is
|
|
531
|
+
* ordinary node data, so any actor could stamp a plausible-looking one onto an
|
|
532
|
+
* unrelated node and get it swept. A node is only removed when the source node
|
|
533
|
+
* says it created it AND the node still carries the matching tag.
|
|
534
|
+
*/
|
|
535
|
+
function axFlowManifest(source: CanvasNodeState): string[] {
|
|
536
|
+
const ids = source.data.axFlowNodeIds;
|
|
537
|
+
return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string') : [];
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* A clear band for the flow: directly under the source panel when that space is
|
|
542
|
+
* free, otherwise the canvas's normal open-position search. The band is placed
|
|
543
|
+
* as ONE rect, so the steps inside it are collision-free by construction.
|
|
544
|
+
*/
|
|
545
|
+
function flowBandOrigin(
|
|
546
|
+
source: CanvasNodeState | undefined,
|
|
547
|
+
obstacles: CanvasPlacementRect[],
|
|
548
|
+
width: number,
|
|
549
|
+
height: number,
|
|
550
|
+
): { x: number; y: number } {
|
|
551
|
+
if (source) {
|
|
552
|
+
const below = { x: source.position.x, y: source.position.y + source.size.height + FLOW_STEP_GAP };
|
|
553
|
+
if (!overlapsAny(below, width, height, obstacles, FLOW_STEP_GAP)) return below;
|
|
554
|
+
}
|
|
555
|
+
return findOpenCanvasPosition(obstacles, width, height, FLOW_STEP_GAP);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Lay a validated, text-only flow out as real canvas nodes and edges.
|
|
560
|
+
*
|
|
561
|
+
* The SERVER owns the entire output shape — one `markdown` node per step, `flow`
|
|
562
|
+
* edges in order, a dashed `references` loop-back edge when looping, and one AX
|
|
563
|
+
* work item per step linked to its node so the existing work-item→node status
|
|
564
|
+
* mirror lights the `axWorkStatus` chip. The caller supplies text only, which is
|
|
565
|
+
* why this can be granted to a sandboxed panel at all.
|
|
566
|
+
*
|
|
567
|
+
* Idempotent: the source node records the ids it created in `data.axFlowNodeIds`
|
|
568
|
+
* and a re-materialize removes exactly those first (removeNode takes their edges
|
|
569
|
+
* with them) instead of stacking a second copy. Work items from a previous
|
|
570
|
+
* materialize survive with their dangling node ref stripped — they are the durable
|
|
571
|
+
* AX record, exactly as for any other node deletion.
|
|
572
|
+
*/
|
|
573
|
+
function materializeFlow(
|
|
574
|
+
manager: AxInteractionManager,
|
|
575
|
+
sourceNode: CanvasNodeState,
|
|
576
|
+
input: AxFlowMaterializeInput,
|
|
577
|
+
source: PmxAxSource,
|
|
578
|
+
): AxInteractionResult {
|
|
579
|
+
const sourceNodeId = sourceNode.id;
|
|
580
|
+
const flowId = axFlowId(sourceNodeId);
|
|
581
|
+
const title = input.title?.trim() || 'Flow';
|
|
582
|
+
const loopEnabled = input.loop?.enabled === true;
|
|
583
|
+
const maxRuns = input.loop?.maxRuns ?? 3;
|
|
584
|
+
|
|
585
|
+
// Replace, never duplicate — and only what this panel actually created: the id
|
|
586
|
+
// must be on the source's manifest AND still carry the matching tag. The source
|
|
587
|
+
// itself is excluded so a self-tagged panel cannot delete itself.
|
|
588
|
+
const owned = new Set(axFlowManifest(sourceNode));
|
|
589
|
+
const stale = manager
|
|
590
|
+
.getLayout()
|
|
591
|
+
.nodes.filter((node) => node.id !== sourceNodeId && owned.has(node.id) && node.data.axFlowId === flowId);
|
|
592
|
+
for (const node of stale) manager.removeNode(node.id);
|
|
593
|
+
|
|
594
|
+
const layout = manager.getLayout();
|
|
595
|
+
const obstacles: CanvasPlacementRect[] = layout.nodes.map((node) => ({ position: node.position, size: node.size }));
|
|
596
|
+
const columns = Math.min(input.steps.length, FLOW_STEP_COLUMNS);
|
|
597
|
+
const rows = Math.ceil(input.steps.length / FLOW_STEP_COLUMNS);
|
|
598
|
+
const origin = flowBandOrigin(
|
|
599
|
+
layout.nodes.find((node) => node.id === sourceNodeId),
|
|
600
|
+
obstacles,
|
|
601
|
+
columns * FLOW_STEP_WIDTH + (columns - 1) * FLOW_STEP_GAP,
|
|
602
|
+
rows * FLOW_STEP_HEIGHT + (rows - 1) * FLOW_STEP_GAP,
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const total = input.steps.length;
|
|
606
|
+
// Last-written data of the anchor step, so its `axFlow` stamp below never has
|
|
607
|
+
// to assume the manager can read a node back.
|
|
608
|
+
let anchorData: Record<string, unknown> = {};
|
|
609
|
+
const steps = input.steps.map((step, index) => {
|
|
610
|
+
const nodeId = `${flowId}-step-${index + 1}`;
|
|
611
|
+
const nodeData: Record<string, unknown> = {
|
|
612
|
+
title: `${index + 1}. ${step.title}`,
|
|
613
|
+
content: step.detail ?? `Step ${index + 1} of ${total} in "${title}".`,
|
|
614
|
+
axFlowId: flowId,
|
|
615
|
+
axFlowStep: index + 1,
|
|
616
|
+
axFlowSourceNodeId: sourceNodeId,
|
|
617
|
+
};
|
|
618
|
+
manager.addNode({
|
|
619
|
+
id: nodeId,
|
|
620
|
+
type: 'markdown',
|
|
621
|
+
position: {
|
|
622
|
+
x: origin.x + (index % FLOW_STEP_COLUMNS) * (FLOW_STEP_WIDTH + FLOW_STEP_GAP),
|
|
623
|
+
y: origin.y + Math.floor(index / FLOW_STEP_COLUMNS) * (FLOW_STEP_HEIGHT + FLOW_STEP_GAP),
|
|
624
|
+
},
|
|
625
|
+
size: { width: FLOW_STEP_WIDTH, height: FLOW_STEP_HEIGHT },
|
|
626
|
+
zIndex: 1,
|
|
627
|
+
collapsed: false,
|
|
628
|
+
pinned: false,
|
|
629
|
+
dockPosition: null,
|
|
630
|
+
data: nodeData,
|
|
631
|
+
});
|
|
632
|
+
const workItem = manager.addWorkItem(
|
|
633
|
+
{
|
|
634
|
+
title: `${title} — ${index + 1}. ${step.title}`,
|
|
635
|
+
status: 'todo',
|
|
636
|
+
detail: step.detail ?? null,
|
|
637
|
+
nodeIds: [nodeId],
|
|
638
|
+
},
|
|
639
|
+
{ source },
|
|
640
|
+
);
|
|
641
|
+
// `data.axStep` is the contract the native step controls read (see
|
|
642
|
+
// shared/ax-flow.ts). It can only be stamped once the work item exists, and
|
|
643
|
+
// the node is re-read first because addWorkItem's status mirror has already
|
|
644
|
+
// written `axWorkStatus` onto it.
|
|
645
|
+
const axStep: AxFlowStepStamp = { flowId, index: index + 1, total, workItemId: workItem.id };
|
|
646
|
+
const stamped = { ...(manager.getNode(nodeId)?.data ?? nodeData), axStep };
|
|
647
|
+
manager.updateNode(nodeId, { data: stamped });
|
|
648
|
+
if (index === 0) anchorData = stamped;
|
|
649
|
+
return { index: index + 1, title: step.title, nodeId, workItemId: workItem.id };
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const edgeIds: string[] = [];
|
|
653
|
+
for (let i = 0; i < steps.length - 1; i++) {
|
|
654
|
+
const edge: CanvasEdge = {
|
|
655
|
+
id: `${flowId}-edge-${i + 1}`,
|
|
656
|
+
from: steps[i].nodeId,
|
|
657
|
+
to: steps[i + 1].nodeId,
|
|
658
|
+
type: 'flow',
|
|
659
|
+
style: 'solid',
|
|
660
|
+
};
|
|
661
|
+
if (manager.addEdge(edge)) edgeIds.push(edge.id);
|
|
662
|
+
}
|
|
663
|
+
if (loopEnabled && steps.length > 1) {
|
|
664
|
+
const loopEdge: CanvasEdge = {
|
|
665
|
+
id: `${flowId}-loop`,
|
|
666
|
+
from: steps[steps.length - 1].nodeId,
|
|
667
|
+
to: steps[0].nodeId,
|
|
668
|
+
type: 'references',
|
|
669
|
+
label: 'loop',
|
|
670
|
+
style: 'dashed',
|
|
671
|
+
animated: true,
|
|
672
|
+
};
|
|
673
|
+
if (manager.addEdge(loopEdge)) edgeIds.push(loopEdge.id);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// `data.axFlow` on the anchor (first) step: the whole flow plus its loop state,
|
|
677
|
+
// so the anchor's native controls can run/stop the loop and the server loop
|
|
678
|
+
// (ax-flow-loop.ts) can advance it. Always stamped, at rest (`running: false`,
|
|
679
|
+
// no completed passes) — a loop only ever starts from an explicit human click.
|
|
680
|
+
const anchorId = steps[0].nodeId;
|
|
681
|
+
const axFlow: AxFlowStamp = {
|
|
682
|
+
flowId,
|
|
683
|
+
title,
|
|
684
|
+
steps: steps.map((step) => ({
|
|
685
|
+
index: step.index,
|
|
686
|
+
nodeId: step.nodeId,
|
|
687
|
+
workItemId: step.workItemId,
|
|
688
|
+
title: step.title,
|
|
689
|
+
})),
|
|
690
|
+
loop: { running: false, run: 0, maxRuns: clampAxFlowMaxRuns(maxRuns) },
|
|
691
|
+
};
|
|
692
|
+
manager.updateNode(anchorId, { data: { ...(manager.getNode(anchorId)?.data ?? anchorData), axFlow } });
|
|
693
|
+
|
|
694
|
+
// The manifest the NEXT materialize sweeps from.
|
|
695
|
+
manager.updateNode(sourceNodeId, {
|
|
696
|
+
data: { ...sourceNode.data, axFlowNodeIds: steps.map((step) => step.nodeId) },
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const primitive = {
|
|
700
|
+
flowId,
|
|
701
|
+
title,
|
|
702
|
+
loop: { enabled: loopEnabled, maxRuns },
|
|
703
|
+
replacedNodeCount: stale.length,
|
|
704
|
+
steps,
|
|
705
|
+
edgeIds,
|
|
706
|
+
};
|
|
707
|
+
return {
|
|
708
|
+
result: { ok: true, type: 'ax.flow.materialize', sourceNodeId, primitive },
|
|
709
|
+
events: [
|
|
710
|
+
outcomeEvent({ ok: true, type: 'ax.flow.materialize', sourceNodeId }),
|
|
711
|
+
{ event: 'canvas-layout-update', payload: { layout: manager.getLayout() } },
|
|
712
|
+
{ event: 'ax-state-changed', payload: { flow: primitive } },
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
431
717
|
/**
|
|
432
718
|
* Validate + execute a node-originated AX interaction. Returns the public result
|
|
433
719
|
* plus the SSE events the caller should emit (accepted/rejected outcome + the
|
|
@@ -548,7 +834,9 @@ export function applyAxInteraction(
|
|
|
548
834
|
},
|
|
549
835
|
opts,
|
|
550
836
|
);
|
|
551
|
-
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem })
|
|
837
|
+
return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
|
|
838
|
+
workItem,
|
|
839
|
+
]);
|
|
552
840
|
}
|
|
553
841
|
case 'ax.work.update': {
|
|
554
842
|
const p = payloadParsed.data as {
|
|
@@ -562,7 +850,9 @@ export function applyAxInteraction(
|
|
|
562
850
|
if (scoped && patch.nodeIds !== undefined) patch.nodeIds = [sourceNodeId];
|
|
563
851
|
const workItem = manager.updateWorkItem(id, patch, opts);
|
|
564
852
|
if (!workItem) return reject(type, sourceNodeId, 404, 'work-item-not-found', `Work item "${id}" not found.`);
|
|
565
|
-
return accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem })
|
|
853
|
+
return withLayoutBroadcast(accept(type, sourceNodeId, workItem, 'ax-state-changed', { workItem }), manager, [
|
|
854
|
+
workItem,
|
|
855
|
+
]);
|
|
566
856
|
}
|
|
567
857
|
case 'ax.evidence.add': {
|
|
568
858
|
const p = payloadParsed.data as {
|
|
@@ -685,6 +975,11 @@ export function applyAxInteraction(
|
|
|
685
975
|
if (!event) return reject(type, sourceNodeId, 400, 'unknown-command', `Unknown command "${p.name}".`);
|
|
686
976
|
return accept(type, sourceNodeId, event, 'ax-event-created', { event });
|
|
687
977
|
}
|
|
978
|
+
case 'ax.flow.materialize':
|
|
979
|
+
// No `nodeIds` in this payload, so `scopedNodeIds` never applies: the step
|
|
980
|
+
// nodes are created BY the server and their work-item links are server-owned
|
|
981
|
+
// ids, not caller-supplied ones. Sandboxed callers pass through unchanged.
|
|
982
|
+
return materializeFlow(manager, node, payloadParsed.data as AxFlowMaterializeInput, source);
|
|
688
983
|
default:
|
|
689
984
|
return reject(type, sourceNodeId, 501, 'not-executable', `"${type}" is recognized but not yet executable.`);
|
|
690
985
|
}
|