@worca/app 1.0.0 → 1.1.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/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/shared/graph/geometry.mjs
|
|
2
|
+
// THE geometry: card sizing, port anchors and model-driven card/port hit tests.
|
|
3
|
+
// Wire SHAPE lives in route.mjs (the orthogonal router), which imports
|
|
4
|
+
// WIRE_HIT_TOL from here. Framework-free and DOM-free so the whole render path
|
|
5
|
+
// derives from the
|
|
6
|
+
// model's x/y — zero getBoundingClientRect on the pointer path, and every claim
|
|
7
|
+
// is unit-testable without jsdom. style.css consumes these numbers ONLY through
|
|
8
|
+
// the --gv-* custom properties injectGeometry writes, so the CSS box model can
|
|
9
|
+
// never drift from nodeSize.
|
|
10
|
+
export const NODE_W = 220;
|
|
11
|
+
export const HEAD_H = 34;
|
|
12
|
+
export const ROW_H = 24;
|
|
13
|
+
export const SEP_H = 9;
|
|
14
|
+
export const PAD_T = 8.5;
|
|
15
|
+
export const PAD_B = 8;
|
|
16
|
+
export const BORDER = 1.5;
|
|
17
|
+
export const DOT = 10;
|
|
18
|
+
export const FOOT_H = 26;
|
|
19
|
+
export const EXEC_ROW_H = 22;
|
|
20
|
+
/** Sub-agent fan squares per wrapped line. The squares are 7px + 3px gap, so a
|
|
21
|
+
* full line is 16·10 − 3 = 157px (--gv-fan-w), leaving the ×N tail its column. */
|
|
22
|
+
export const FAN_PER_ROW = 16;
|
|
23
|
+
export const FAN_ROW_W = FAN_PER_ROW * 10 - 3;
|
|
24
|
+
export const SNAP = 11;
|
|
25
|
+
export const PORT_HIT_R = 14;
|
|
26
|
+
export const WIRE_HIT_TOL = 6;
|
|
27
|
+
export const ZOOM_MIN = 0.4;
|
|
28
|
+
export const ZOOM_MAX = 1.6;
|
|
29
|
+
export const ZOOM_K = 0.002;
|
|
30
|
+
/** First row centre from the top of the card: 1.5 + 34 + 8.5 + 12. */
|
|
31
|
+
export const ROW0 = BORDER + HEAD_H + PAD_T + ROW_H / 2;
|
|
32
|
+
|
|
33
|
+
/** Every CSS-visible number, as the custom properties style.css reads. */
|
|
34
|
+
export const GEOMETRY_CSS_VARS = Object.freeze({
|
|
35
|
+
'--gv-node-w': `${NODE_W}px`, '--gv-head-h': `${HEAD_H}px`, '--gv-row-h': `${ROW_H}px`,
|
|
36
|
+
'--gv-sep-h': `${SEP_H}px`, '--gv-pad-t': `${PAD_T}px`, '--gv-pad-b': `${PAD_B}px`,
|
|
37
|
+
'--gv-border': `${BORDER}px`, '--gv-dot': `${DOT}px`, '--gv-foot-h': `${FOOT_H}px`,
|
|
38
|
+
'--gv-exec-row-h': `${EXEC_ROW_H}px`, '--gv-fan-w': `${FAN_ROW_W}px`,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Write the variables onto a host element at mount. Guarded: jsdom hosts and a
|
|
42
|
+
* missing element are both fine (the caller is a renderer, not a validator). */
|
|
43
|
+
export function injectGeometry(el) {
|
|
44
|
+
if (!el || !el.style || typeof el.style.setProperty !== 'function') return;
|
|
45
|
+
for (const [name, value] of Object.entries(GEOMETRY_CSS_VARS)) el.style.setProperty(name, value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CAPTION_SET = new Set(['task', 'end', 'or']);
|
|
49
|
+
const metaInputs = (ports) => (Array.isArray(ports?.inputs) ? ports.inputs : []).filter((p) => !p?.synthetic);
|
|
50
|
+
const hasAwaitRow = (ports) => (Array.isArray(ports?.inputs) ? ports.inputs : []).some((p) => p?.synthetic);
|
|
51
|
+
const outs = (ports) => (Array.isArray(ports?.outputs) ? ports.outputs : []);
|
|
52
|
+
|
|
53
|
+
/** Zones top to bottom: inputs -> outputs -> await gate (agents) -> caption
|
|
54
|
+
* (task/end/or). A zone is emitted only when NON-EMPTY and a separator sits
|
|
55
|
+
* only BETWEEN emitted zones — that is what reproduces the closed forms and
|
|
56
|
+
* degrades sanely on a 0-input card. */
|
|
57
|
+
function zones(node, ports) {
|
|
58
|
+
const z = [];
|
|
59
|
+
const ins = metaInputs(ports).length;
|
|
60
|
+
if (ins) z.push({ kind: 'in', n: ins });
|
|
61
|
+
if (outs(ports).length) z.push({ kind: 'out', n: outs(ports).length });
|
|
62
|
+
if (hasAwaitRow(ports)) z.push({ kind: 'await', n: 1 });
|
|
63
|
+
if (CAPTION_SET.has(node?.kind)) z.push({ kind: 'cap', n: 1 });
|
|
64
|
+
return z;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** y offset of a zone's FIRST row centre, or null when the zone is not emitted. */
|
|
68
|
+
function zoneTop(node, ports, kind) {
|
|
69
|
+
let y = ROW0;
|
|
70
|
+
for (const z of zones(node, ports)) {
|
|
71
|
+
if (z.kind === kind) return y;
|
|
72
|
+
y += z.n * ROW_H + SEP_H;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Wrapped lines a fan of `n` squares occupies (the leds are pre-capped upstream). */
|
|
78
|
+
export function fanLines(n) {
|
|
79
|
+
return Math.max(1, Math.ceil((Number(n) || 0) / FAN_PER_ROW));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{kind:string}} node
|
|
84
|
+
* @param {{inputs:Array, outputs:Array}} ports RESOLVED ports (await included for agents)
|
|
85
|
+
* @param {{footerRows?:number}} [opts] footer LINES: 0 none · 1 collapsed executions
|
|
86
|
+
* strip · more for extra lines (a wrapped fan line, a stacked exec row's extra
|
|
87
|
+
* lines). The first line is FOOT_H tall, every further one EXEC_ROW_H.
|
|
88
|
+
*/
|
|
89
|
+
export function nodeSize(node, ports, { footerRows = 0 } = {}) {
|
|
90
|
+
const zs = zones(node, ports);
|
|
91
|
+
const rows = zs.reduce((s, z) => s + z.n, 0);
|
|
92
|
+
const seps = Math.max(0, zs.length - 1);
|
|
93
|
+
const footer = footerRows ? FOOT_H + (footerRows - 1) * EXEC_ROW_H : 0;
|
|
94
|
+
return { w: NODE_W, h: 2 * BORDER + HEAD_H + PAD_T + rows * ROW_H + seps * SEP_H + PAD_B + footer };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Inputs and the await gate anchor on the LEFT edge, outputs on the RIGHT.
|
|
98
|
+
* The footer is the bottom-most box, so no anchor depends on it. */
|
|
99
|
+
export function portAnchor(node, ports, portId, dir) {
|
|
100
|
+
if (dir === 'in' && portId === 'await' && hasAwaitRow(ports)) {
|
|
101
|
+
return { x: node.x, y: node.y + zoneTop(node, ports, 'await') };
|
|
102
|
+
}
|
|
103
|
+
if (dir === 'in') {
|
|
104
|
+
const i = metaInputs(ports).findIndex((p) => p?.id === portId);
|
|
105
|
+
const top = zoneTop(node, ports, 'in');
|
|
106
|
+
return i < 0 || top === null ? null : { x: node.x, y: node.y + top + ROW_H * i };
|
|
107
|
+
}
|
|
108
|
+
const j = outs(ports).findIndex((p) => p?.id === portId);
|
|
109
|
+
const top = zoneTop(node, ports, 'out');
|
|
110
|
+
return j < 0 || top === null ? null : { x: node.x + NODE_W, y: node.y + top + ROW_H * j };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
114
|
+
|
|
115
|
+
/** Snap to the 11px half-grid (the 22px dot grid's half step). DRAG only —
|
|
116
|
+
* loaded templates render at their authored positions, unsnapped. */
|
|
117
|
+
export function snap(value, grid = SNAP) {
|
|
118
|
+
return Math.round(value / grid) * grid;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function hitNode(node, size, pt) {
|
|
122
|
+
return pt.x >= node.x && pt.x <= node.x + size.w && pt.y >= node.y && pt.y <= node.y + size.h;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function hitPort(anchor, pt, r = PORT_HIT_R) {
|
|
126
|
+
return Math.hypot(pt.x - anchor.x, pt.y - anchor.y) <= r;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The union of the card boxes, optionally padded. `footerRowsOf(node)` lets the
|
|
130
|
+
* run monitor fit an expanded executions footer. null when there is nothing. */
|
|
131
|
+
export function graphBounds(tpl, portsFn, { pad = 0, footerRowsOf } = {}) {
|
|
132
|
+
// OBJECTS only: `filter(Boolean)` kept a truthy non-object (`7`), sized it as a
|
|
133
|
+
// card at the origin and stretched the bounds of every fit built from it.
|
|
134
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : [])
|
|
135
|
+
.filter((n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n));
|
|
136
|
+
if (!nodes.length) return null;
|
|
137
|
+
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
|
|
138
|
+
for (const node of nodes) {
|
|
139
|
+
const ports = (typeof portsFn === 'function' ? portsFn(node) : null) || { inputs: [], outputs: [] };
|
|
140
|
+
const size = nodeSize(node, ports, { footerRows: footerRowsOf ? footerRowsOf(node) : 0 });
|
|
141
|
+
const x = Number(node.x) || 0;
|
|
142
|
+
const y = Number(node.y) || 0;
|
|
143
|
+
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
|
144
|
+
maxX = Math.max(maxX, x + size.w); maxY = Math.max(maxY, y + size.h);
|
|
145
|
+
}
|
|
146
|
+
return { x: minX - pad, y: minY - pad, w: maxX - minX + 2 * pad, h: maxY - minY + 2 * pad };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Fit `bounds` into a viewport: `screen = world·z + t`. zoomMax defaults to 1 —
|
|
150
|
+
* auto-fit NEVER magnifies past 1x (spec §7.6). */
|
|
151
|
+
export function fitBounds(bounds, viewport, { zoomMin = ZOOM_MIN, zoomMax = 1 } = {}) {
|
|
152
|
+
const width = Number(viewport?.width) || 0;
|
|
153
|
+
const height = Number(viewport?.height) || 0;
|
|
154
|
+
if (!bounds || !(bounds.w > 0) || !(bounds.h > 0) || width <= 0 || height <= 0) return { z: 1, tx: 0, ty: 0 };
|
|
155
|
+
const z = clamp(Math.min(width / bounds.w, height / bounds.h), zoomMin, zoomMax);
|
|
156
|
+
return { z, tx: (width - bounds.w * z) / 2 - bounds.x * z, ty: (height - bounds.h * z) / 2 - bounds.y * z };
|
|
157
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// src/shared/graph/layout.mjs
|
|
2
|
+
// Auto-layout for the composer's header button: longest-path ranks with LOOP
|
|
3
|
+
// WIRES EXCLUDED, columns at x = 60 + rank*320, barycenter ordering inside each
|
|
4
|
+
// column, y stacked so no two cards touch and snapped to the 11px grid.
|
|
5
|
+
// Deterministic by construction — same template in, same positions out, and
|
|
6
|
+
// re-running over an already laid-out template reproduces it exactly.
|
|
7
|
+
import { classifyLoops } from './loops.mjs';
|
|
8
|
+
import { nodeSize, snap } from './geometry.mjs';
|
|
9
|
+
|
|
10
|
+
export const RANK_X0 = 60;
|
|
11
|
+
export const RANK_DX = 320; // 100px column gap: two 24px clearance bands + laned verticals
|
|
12
|
+
export const RANK_Y0 = 60;
|
|
13
|
+
export const ROW_GAP = 64; // > 2×ROUTE_CLEARANCE so a wire corridor survives between stacked cards
|
|
14
|
+
const SWEEPS = 2;
|
|
15
|
+
|
|
16
|
+
/** A layoutable node: an object with a string id. `filter(Boolean)` let a truthy
|
|
17
|
+
* non-object through and indexed an id-less node under `undefined`, so a wire
|
|
18
|
+
* with a missing endpoint resolved through it and threw. */
|
|
19
|
+
const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
|
|
20
|
+
|
|
21
|
+
/** @param {object} tpl @param {{loopWireIds:Set<string>}} loops classifyLoops() output */
|
|
22
|
+
export function rankNodes(tpl, loops) {
|
|
23
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
24
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
25
|
+
const edges = nonLoopEdges(tpl, loops, ids);
|
|
26
|
+
|
|
27
|
+
const rank = {};
|
|
28
|
+
const indegree = {};
|
|
29
|
+
for (const id of ids) { rank[id] = 0; indegree[id] = 0; }
|
|
30
|
+
const out = new Map();
|
|
31
|
+
for (const e of edges) {
|
|
32
|
+
if (!out.has(e.from)) out.set(e.from, []);
|
|
33
|
+
out.get(e.from).push(e.to);
|
|
34
|
+
indegree[e.to] += 1;
|
|
35
|
+
}
|
|
36
|
+
// Kahn with a SORTED frontier: ties break by node id, so the walk order — and
|
|
37
|
+
// therefore the result — never depends on declaration order.
|
|
38
|
+
const ready = [...ids].filter((id) => indegree[id] === 0).sort();
|
|
39
|
+
const settled = new Set();
|
|
40
|
+
while (ready.length) {
|
|
41
|
+
const id = ready.shift();
|
|
42
|
+
settled.add(id);
|
|
43
|
+
for (const next of out.get(id) || []) {
|
|
44
|
+
rank[next] = Math.max(rank[next], rank[id] + 1);
|
|
45
|
+
indegree[next] -= 1;
|
|
46
|
+
if (indegree[next] === 0) insertSorted(ready, next);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// A residual cycle survives only when no loop wire cuts it (V10 blocks SAVING
|
|
50
|
+
// such a graph, but the editor still has to draw it). Rank the leftovers from
|
|
51
|
+
// whatever settled feeds them, in id order — bounded, never a hang.
|
|
52
|
+
for (const id of [...ids].filter((n) => !settled.has(n)).sort()) {
|
|
53
|
+
rank[id] = edges.filter((e) => e.to === id && settled.has(e.from))
|
|
54
|
+
.reduce((best, e) => Math.max(best, rank[e.from] + 1), 0);
|
|
55
|
+
settled.add(id);
|
|
56
|
+
}
|
|
57
|
+
return rank;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns {{[nodeId:string]: {x:number, y:number}}} — the caller applies them. */
|
|
61
|
+
export function autoLayout(tpl, portsFn, { x0 = RANK_X0, dx = RANK_DX, y0 = RANK_Y0, gap = ROW_GAP } = {}) {
|
|
62
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
63
|
+
const loops = classifyLoops(tpl, portsFn);
|
|
64
|
+
const rank = rankNodes(tpl, loops);
|
|
65
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
66
|
+
const edges = nonLoopEdges(tpl, loops, ids);
|
|
67
|
+
|
|
68
|
+
const columns = new Map();
|
|
69
|
+
for (const n of nodes) {
|
|
70
|
+
if (!columns.has(rank[n.id])) columns.set(rank[n.id], []);
|
|
71
|
+
columns.get(rank[n.id]).push(n.id);
|
|
72
|
+
}
|
|
73
|
+
const ranksAscending = [...columns.keys()].sort((a, b) => a - b);
|
|
74
|
+
|
|
75
|
+
// Barycenter: sweep left to right, ordering each column by the mean row index
|
|
76
|
+
// of its predecessors. A node with no ranked predecessor keeps its index, so
|
|
77
|
+
// the pass is stable.
|
|
78
|
+
const preds = new Map();
|
|
79
|
+
for (const e of edges) {
|
|
80
|
+
if (!preds.has(e.to)) preds.set(e.to, []);
|
|
81
|
+
preds.get(e.to).push(e.from);
|
|
82
|
+
}
|
|
83
|
+
for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
|
|
84
|
+
for (const r of ranksAscending) {
|
|
85
|
+
const rowOf = new Map();
|
|
86
|
+
for (const prevRank of ranksAscending) {
|
|
87
|
+
if (prevRank >= r) break;
|
|
88
|
+
columns.get(prevRank).forEach((id, i) => rowOf.set(id, i));
|
|
89
|
+
}
|
|
90
|
+
const keyed = columns.get(r).map((id, i) => ({ id, i, bary: barycenter(preds.get(id), rowOf, i) }));
|
|
91
|
+
keyed.sort((a, b) => a.bary - b.bary || a.i - b.i);
|
|
92
|
+
columns.set(r, keyed.map((k) => k.id));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
97
|
+
const positions = {};
|
|
98
|
+
for (const r of ranksAscending) {
|
|
99
|
+
let cursor = y0;
|
|
100
|
+
for (const id of columns.get(r)) {
|
|
101
|
+
const node = byId.get(id);
|
|
102
|
+
const ports = (typeof portsFn === 'function' ? portsFn(node) : null) || { inputs: [], outputs: [] };
|
|
103
|
+
const { h } = nodeSize(node, ports);
|
|
104
|
+
const y = snap(cursor);
|
|
105
|
+
positions[id] = { x: x0 + r * dx, y };
|
|
106
|
+
cursor = y + h + gap; // stack from the SNAPPED row: idempotent
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return positions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function nonLoopEdges(tpl, loops, ids) {
|
|
113
|
+
const loopWireIds = loops?.loopWireIds instanceof Set ? loops.loopWireIds : new Set();
|
|
114
|
+
return (Array.isArray(tpl?.wires) ? tpl.wires : [])
|
|
115
|
+
.filter((w) => ids.has(w?.from?.node) && ids.has(w?.to?.node)
|
|
116
|
+
&& w.from.node !== w.to.node && !loopWireIds.has(w.id))
|
|
117
|
+
.map((w) => ({ from: w.from.node, to: w.to.node }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function barycenter(predecessors, rowOf, fallback) {
|
|
121
|
+
const rows = (predecessors || []).map((id) => rowOf.get(id)).filter((row) => row !== undefined);
|
|
122
|
+
if (!rows.length) return fallback;
|
|
123
|
+
return rows.reduce((sum, row) => sum + row, 0) / rows.length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function insertSorted(list, id) {
|
|
127
|
+
let lo = 0;
|
|
128
|
+
let hi = list.length;
|
|
129
|
+
while (lo < hi) {
|
|
130
|
+
const mid = (lo + hi) >> 1;
|
|
131
|
+
if (list[mid] < id) lo = mid + 1; else hi = mid;
|
|
132
|
+
}
|
|
133
|
+
list.splice(lo, 0, id);
|
|
134
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/shared/graph/loops.mjs
|
|
2
|
+
// Loop classification and launch order. Two orthogonal concepts (base spec §2):
|
|
3
|
+
// loop WIRE (budget + amber styling) = both endpoints in ONE nontrivial SCC
|
|
4
|
+
// (or a self-wire) AND the source output is `when:'blocking'`.
|
|
5
|
+
// loop INPUT (firing semantics) = an input declared `loop:true` in meta;
|
|
6
|
+
// wiring-independent, so an unwired one is still in the set.
|
|
7
|
+
// Pure and crash-free: dangling endpoints and unknown agent keys are the
|
|
8
|
+
// validator's errors (V5/V4), so they are filtered, never thrown on.
|
|
9
|
+
import { portsOf, findPort } from './ports.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Iterative Tarjan (an explicit stack — recursion depth is not a property worth
|
|
13
|
+
* depending on). Roots are visited in sorted id order and every component is
|
|
14
|
+
* returned sorted, so the result is reproducible run to run.
|
|
15
|
+
* @param {string[]} ids
|
|
16
|
+
* @param {Array<{from:string, to:string}>} edges
|
|
17
|
+
* @returns {string[][]}
|
|
18
|
+
*/
|
|
19
|
+
export function tarjanSccs(ids, edges) {
|
|
20
|
+
const adj = new Map((Array.isArray(ids) ? ids : []).map((id) => [id, []]));
|
|
21
|
+
for (const e of Array.isArray(edges) ? edges : []) {
|
|
22
|
+
if (adj.has(e?.from) && adj.has(e?.to)) adj.get(e.from).push(e.to);
|
|
23
|
+
}
|
|
24
|
+
for (const tos of adj.values()) tos.sort();
|
|
25
|
+
const index = new Map(); const low = new Map(); const onStack = new Set();
|
|
26
|
+
const stack = []; const sccs = []; let counter = 0;
|
|
27
|
+
for (const root of [...adj.keys()].sort()) {
|
|
28
|
+
if (index.has(root)) continue;
|
|
29
|
+
index.set(root, counter); low.set(root, counter); counter += 1;
|
|
30
|
+
stack.push(root); onStack.add(root);
|
|
31
|
+
const work = [{ id: root, edges: adj.get(root) || [], at: 0 }];
|
|
32
|
+
while (work.length) {
|
|
33
|
+
const frame = work[work.length - 1];
|
|
34
|
+
if (frame.at < frame.edges.length) {
|
|
35
|
+
const next = frame.edges[frame.at];
|
|
36
|
+
frame.at += 1;
|
|
37
|
+
if (!index.has(next)) {
|
|
38
|
+
index.set(next, counter); low.set(next, counter); counter += 1;
|
|
39
|
+
stack.push(next); onStack.add(next);
|
|
40
|
+
work.push({ id: next, edges: adj.get(next) || [], at: 0 });
|
|
41
|
+
} else if (onStack.has(next)) {
|
|
42
|
+
low.set(frame.id, Math.min(low.get(frame.id), index.get(next)));
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
work.pop();
|
|
47
|
+
if (work.length) {
|
|
48
|
+
const parent = work[work.length - 1].id;
|
|
49
|
+
low.set(parent, Math.min(low.get(parent), low.get(frame.id)));
|
|
50
|
+
}
|
|
51
|
+
if (low.get(frame.id) === index.get(frame.id)) {
|
|
52
|
+
const scc = [];
|
|
53
|
+
for (;;) { const id = stack.pop(); onStack.delete(id); scc.push(id); if (id === frame.id) break; }
|
|
54
|
+
sccs.push(scc.sort());
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return sccs;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} tpl v2 template { nodes, wires }
|
|
63
|
+
* @param {(node:object) => object|undefined} portsFn
|
|
64
|
+
* @returns {{loopWireIds:Set<string>, loopInputs:Set<string>, sccOf:Map<string,number>, launchOrder:string[]}}
|
|
65
|
+
* `loopInputs` is graph-global (`'<nodeId>.<port>'`).
|
|
66
|
+
*/
|
|
67
|
+
export function classifyLoops(tpl, portsFn) {
|
|
68
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : [])
|
|
69
|
+
.filter((n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n));
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
// STRING ids only — an id-less node indexed under `undefined` would make the
|
|
72
|
+
// wire filter below accept a wire with a missing `from`/`to`, and the map on
|
|
73
|
+
// the next line would throw on it (V2/V5 are the validator's to report).
|
|
74
|
+
for (const n of nodes) if (typeof n.id === 'string' && !byId.has(n.id)) byId.set(n.id, n);
|
|
75
|
+
const wires = (Array.isArray(tpl?.wires) ? tpl.wires : [])
|
|
76
|
+
.filter((w) => byId.has(w?.from?.node) && byId.has(w?.to?.node));
|
|
77
|
+
|
|
78
|
+
const ids = [...byId.keys()];
|
|
79
|
+
const sccs = tarjanSccs(ids, wires.map((w) => ({ from: w.from.node, to: w.to.node })));
|
|
80
|
+
const sccOf = new Map();
|
|
81
|
+
sccs.forEach((scc, i) => scc.forEach((id) => sccOf.set(id, i)));
|
|
82
|
+
const nontrivial = new Set(sccs.map((scc, i) => (scc.length > 1 ? i : -1)).filter((i) => i >= 0));
|
|
83
|
+
const selfWired = new Set(wires.filter((w) => w.from.node === w.to.node).map((w) => w.from.node));
|
|
84
|
+
|
|
85
|
+
const loopWireIds = new Set();
|
|
86
|
+
for (const w of wires) {
|
|
87
|
+
const a = sccOf.get(w.from.node);
|
|
88
|
+
if (a === undefined || a !== sccOf.get(w.to.node)) continue;
|
|
89
|
+
if (!nontrivial.has(a) && !selfWired.has(w.from.node)) continue;
|
|
90
|
+
const out = findPort(portsOf(portsFn, byId.get(w.from.node)), w.from.port, 'out');
|
|
91
|
+
if (out && out.when === 'blocking') loopWireIds.add(w.id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const loopInputs = new Set();
|
|
95
|
+
for (const n of nodes) {
|
|
96
|
+
for (const inp of portsOf(portsFn, n).inputs) {
|
|
97
|
+
if (inp?.loop) loopInputs.add(`${n.id}.${inp.id}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { loopWireIds, loopInputs, sccOf, launchOrder: condensationTopo(sccs, wires) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Kahn over the condensation: ties break by the component's minimum node id
|
|
105
|
+
* (members are sorted), which is what makes the launch order reproducible.
|
|
106
|
+
* Parallel wires collapse to one condensation edge so in-degrees stay balanced. */
|
|
107
|
+
function condensationTopo(sccs, wires) {
|
|
108
|
+
const sccOf = new Map();
|
|
109
|
+
sccs.forEach((scc, i) => scc.forEach((id) => sccOf.set(id, i)));
|
|
110
|
+
const succ = sccs.map(() => new Set());
|
|
111
|
+
const indegree = sccs.map(() => 0);
|
|
112
|
+
for (const w of wires) {
|
|
113
|
+
const a = sccOf.get(w.from.node);
|
|
114
|
+
const b = sccOf.get(w.to.node);
|
|
115
|
+
if (a === undefined || b === undefined || a === b || succ[a].has(b)) continue;
|
|
116
|
+
succ[a].add(b);
|
|
117
|
+
indegree[b] += 1;
|
|
118
|
+
}
|
|
119
|
+
const remaining = new Set(sccs.map((_, i) => i));
|
|
120
|
+
const order = [];
|
|
121
|
+
while (remaining.size) {
|
|
122
|
+
let pick = -1;
|
|
123
|
+
for (const i of remaining) if (indegree[i] === 0 && (pick < 0 || sccs[i][0] < sccs[pick][0])) pick = i;
|
|
124
|
+
if (pick < 0) break; // acyclic by construction — defensive only
|
|
125
|
+
remaining.delete(pick);
|
|
126
|
+
order.push(...sccs[pick]);
|
|
127
|
+
for (const b of succ[pick]) indegree[b] -= 1;
|
|
128
|
+
}
|
|
129
|
+
return order;
|
|
130
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// src/shared/graph/manifest.mjs
|
|
2
|
+
// The run-start snapshot persisted as pipelines.stepper. SELF-SUFFICIENT by
|
|
3
|
+
// design: History renders it when the registry is gone or edited, so nothing
|
|
4
|
+
// here may be re-resolved later. Built once per run (and by resume()), NEVER
|
|
5
|
+
// rewritten mid-run — fan-out lives in the execution ledger, not the manifest.
|
|
6
|
+
//
|
|
7
|
+
// It also carries DERIVED `steps` cells and `feedbacks` in the shape the v1
|
|
8
|
+
// buildStepperManifest used to produce. P1's handoff said P8 would delete them;
|
|
9
|
+
// it does NOT, and deliberately: they have LIVE v2 readers (findManifestNode,
|
|
10
|
+
// cycleAwareLabel, nodeLabelLookup, manifestStepsForWires, loopCounts), so they
|
|
11
|
+
// are a real part of the v2 manifest now, not a shim. What made them safe is
|
|
12
|
+
// manifestFor() returning an EMPTY manifest instead of the v1 default seven.
|
|
13
|
+
// UI_PHASE survives for the same reason: `:116` stamps `uiPhase` on every v2
|
|
14
|
+
// agent cell, and the sub_agents.ui_phase attribution column still needs it. The
|
|
15
|
+
// workflows.mjs copy is gone (the v1 topology helpers went with the v1 engine),
|
|
16
|
+
// so THIS is the only copy — shared code may not import workflows.mjs.
|
|
17
|
+
import { TEMPLATE_VERSION, AWAIT_PORT, DEFAULT_MAX_CYCLES, FLOW_LABEL } from './constants.mjs';
|
|
18
|
+
import { portsFnFor, portsOf, resolveOrOutType } from './ports.mjs';
|
|
19
|
+
import { classifyLoops } from './loops.mjs';
|
|
20
|
+
import { rankNodes } from './layout.mjs';
|
|
21
|
+
|
|
22
|
+
/** Agent key -> the UI stepper bucket. The only copy: the v1 original left
|
|
23
|
+
* workflows.mjs with the topology helpers. */
|
|
24
|
+
export const UI_PHASE = Object.freeze({
|
|
25
|
+
clarify: 'clarify',
|
|
26
|
+
planner: 'plan', refiner: 'refine', decomposer: 'decompose', implementer: 'implement', reviewer: 'review',
|
|
27
|
+
manualTestsChecklist: 'manual-checklist', manualWebUiTesting: 'manual-web', planReviewer: 'plan-review',
|
|
28
|
+
workspaceReviewer: 'review',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const ICON_MAX = 2048;
|
|
32
|
+
/** The icon is an ALLOWLIST, not a denylist: a sidecar can be user- or
|
|
33
|
+
* plugin-authored (exactly what v1's UI refuses to inline — `safeAgentIcon`),
|
|
34
|
+
* the markup rides the manifest into innerHTML, and the manifest is persisted,
|
|
35
|
+
* so it outlives any renderer-side fix. A denylist of `on…=` handlers misses
|
|
36
|
+
* `/`, `"` and `'` as attribute separators and every entity encoding; these
|
|
37
|
+
* three tables cover the whole shipped icon vocabulary instead. */
|
|
38
|
+
const ICON_TAGS = new Set(['path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'g']);
|
|
39
|
+
const ICON_ATTRS = new Set(['d', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
|
|
40
|
+
'width', 'height', 'points', 'transform', 'opacity', 'fill', 'fill-rule', 'fill-opacity',
|
|
41
|
+
'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray',
|
|
42
|
+
'stroke-dashoffset', 'stroke-miterlimit', 'vector-effect', 'clip-rule']);
|
|
43
|
+
// Both alternations are UNAMBIGUOUS at every position (the bare class excludes
|
|
44
|
+
// the quotes), so neither can backtrack quadratically on a malformed icon.
|
|
45
|
+
const ICON_TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g;
|
|
46
|
+
const ICON_ATTR_RE = /([a-zA-Z][a-zA-Z0-9:-]*)\s*=\s*("[^"]*"|'[^']*')/g;
|
|
47
|
+
|
|
48
|
+
/** Inline SVG markup rides the manifest into innerHTML, so anything outside the
|
|
49
|
+
* allowlist is dropped WHOLE (never truncated — a half tag is worse).
|
|
50
|
+
* EXPORTED (C-2): the composer's canvas reaches the SAME untrusted field through
|
|
51
|
+
* GET /api/agents rather than through a manifest, and it had only an
|
|
52
|
+
* `origin === 'user'` denylist — so a plugin sidecar's icon went to innerHTML
|
|
53
|
+
* verbatim. One allowlist, both paths. Idempotent: a string that passes is
|
|
54
|
+
* returned unchanged, so sanitizing an already-sanitized icon is a no-op. */
|
|
55
|
+
export function sanitizeIcon(raw) {
|
|
56
|
+
const s = typeof raw === 'string' ? raw.trim() : '';
|
|
57
|
+
if (!s || s.length > ICON_MAX) return '';
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
ICON_TAG_RE.lastIndex = 0;
|
|
60
|
+
for (let m; (m = ICON_TAG_RE.exec(s));) {
|
|
61
|
+
if (s.slice(cursor, m.index).trim()) return ''; // a text node between tags
|
|
62
|
+
cursor = m.index + m[0].length;
|
|
63
|
+
if (!ICON_TAGS.has(m[2].toLowerCase())) return '';
|
|
64
|
+
if (m[1]) { if (m[3].trim()) return ''; continue; } // a closing tag carries nothing
|
|
65
|
+
if (!iconAttrsOk(m[3])) return '';
|
|
66
|
+
}
|
|
67
|
+
return cursor === s.length ? s : ''; // trailing text or an unclosed tag
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every attribute quoted, named in ICON_ATTRS, and free of `<`/`&` — an
|
|
71
|
+
* entity-encoded `javascript:` decodes only AFTER the filter would have run. */
|
|
72
|
+
function iconAttrsOk(rawAttrs) {
|
|
73
|
+
const attrs = rawAttrs.replace(/\/\s*$/, ''); // the self-closing slash
|
|
74
|
+
let cursor = 0;
|
|
75
|
+
ICON_ATTR_RE.lastIndex = 0;
|
|
76
|
+
for (let m; (m = ICON_ATTR_RE.exec(attrs));) {
|
|
77
|
+
if (attrs.slice(cursor, m.index).trim()) return false; // a bare or unquoted attribute
|
|
78
|
+
cursor = m.index + m[0].length;
|
|
79
|
+
if (!ICON_ATTRS.has(m[1].toLowerCase())) return false;
|
|
80
|
+
if (/[<&]/.test(m[2])) return false;
|
|
81
|
+
}
|
|
82
|
+
return !attrs.slice(cursor).trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isNode = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n) && typeof n.id === 'string';
|
|
86
|
+
const isWire = (w) => Boolean(w) && typeof w === 'object' && !Array.isArray(w)
|
|
87
|
+
&& typeof w?.from?.node === 'string' && typeof w?.to?.node === 'string';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {object} tpl resolved v2 template
|
|
91
|
+
* @param {Record<string,object>} agentsByKey merged registry metas
|
|
92
|
+
* @param {{overlays?:{nodes?:object, wires?:object}}} [opts] effective per-node config + per-wire budgets
|
|
93
|
+
*/
|
|
94
|
+
export function buildGraphManifest(tpl, agentsByKey, opts = {}) {
|
|
95
|
+
const overlays = opts.overlays || {};
|
|
96
|
+
const nodeOverlays = overlays.nodes || {};
|
|
97
|
+
const wireOverlays = overlays.wires || {};
|
|
98
|
+
const portsFn = portsFnFor(agentsByKey);
|
|
99
|
+
// Objects with real endpoints only. `filter(Boolean)` kept a truthy non-object
|
|
100
|
+
// node and an endpoint-less wire, and `w.from.node` threw one map later — the
|
|
101
|
+
// manifest is built from an ALREADY validated template, but it is also built
|
|
102
|
+
// by resume() from a persisted one, so it degrades instead of crashing.
|
|
103
|
+
const nodes = (Array.isArray(tpl?.nodes) ? tpl.nodes : []).filter(isNode);
|
|
104
|
+
const wires = (Array.isArray(tpl?.wires) ? tpl.wires : []).filter(isWire);
|
|
105
|
+
const loops = classifyLoops(tpl, portsFn);
|
|
106
|
+
const ranks = rankNodes(tpl, loops);
|
|
107
|
+
const launchIndex = new Map(loops.launchOrder.map((id, i) => [id, i]));
|
|
108
|
+
const isWired = new Set(wires.map((w) => `${w?.to?.node}.${w?.to?.port}`));
|
|
109
|
+
|
|
110
|
+
const manifestNodes = nodes.map((node) => {
|
|
111
|
+
const resolved = portsOf(portsFn, node);
|
|
112
|
+
const meta = node.kind === 'agent' ? (agentsByKey?.[node.key] || null) : null;
|
|
113
|
+
const over = nodeOverlays[node.id] || {};
|
|
114
|
+
const cfg = node.config || {};
|
|
115
|
+
const outType = (port) => (node.kind === 'or' && (!port.type || port.type === 'any')
|
|
116
|
+
? (resolveOrOutType(tpl, portsFn, node.id) || 'any') : port.type);
|
|
117
|
+
const cell = {
|
|
118
|
+
id: node.id,
|
|
119
|
+
kind: node.kind,
|
|
120
|
+
key: node.kind === 'agent' ? node.key : null,
|
|
121
|
+
x: Number(node.x) || 0,
|
|
122
|
+
y: Number(node.y) || 0,
|
|
123
|
+
label: node.kind === 'agent' ? (meta?.displayName || node.key) : (FLOW_LABEL[node.kind] || node.kind),
|
|
124
|
+
// The v1 stepper bucket. UI_PHASE knows the 11 builtins; a custom agent
|
|
125
|
+
// buckets under its own key (the sidecar's `uiPhase` died with the v1
|
|
126
|
+
// vocabulary). The whole field goes with the phase shim in Task 16.
|
|
127
|
+
uiPhase: node.kind === 'agent' ? (UI_PHASE[node.key] || node.key) : node.kind,
|
|
128
|
+
// The AUTHORED config, verbatim and complete (unknown keys included —
|
|
129
|
+
// V17's "preserved and ignored" promise). The manifest is the ONLY
|
|
130
|
+
// persisted copy of the topology: P4 rebuilds the template from it on a
|
|
131
|
+
// resume and never re-reads the workflow row, so anything dropped here
|
|
132
|
+
// (`planStoreSeed`, a future tunable) is lost for the rest of the run.
|
|
133
|
+
config: { ...(node.config || {}) },
|
|
134
|
+
ports: {
|
|
135
|
+
inputs: resolved.inputs.filter((p) => !p.synthetic).map((p) => ({
|
|
136
|
+
id: p.id, type: p.type, required: p.required !== false, loop: !!p.loop, expands: !!p.expands })),
|
|
137
|
+
outputs: resolved.outputs.map((p) => ({ id: p.id, type: outType(p), when: p.when || 'always' })),
|
|
138
|
+
await: resolved.inputs.some((p) => p.synthetic),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
if (node.kind === 'agent') {
|
|
142
|
+
cell.color = meta?.color || '';
|
|
143
|
+
cell.icon = sanitizeIcon(meta?.icon);
|
|
144
|
+
cell.model = over.model ?? cfg.model ?? '';
|
|
145
|
+
cell.effort = over.effort ?? cfg.effort ?? '';
|
|
146
|
+
cell.askQuestions = !!(over.askQuestions ?? cfg.askQuestions ?? meta?.questionsDefault ?? false);
|
|
147
|
+
cell.awaitAll = !!(over.awaitAll ?? cfg.awaitAll ?? false);
|
|
148
|
+
cell.fanOut = !!(over.fanOut ?? cfg.fanOut ?? meta?.fanOut ?? false);
|
|
149
|
+
// Sub-agent model policy ('' = inherit). Recorded like model/effort so a
|
|
150
|
+
// RESUMED run rebuilds the same child-model behavior from the manifest
|
|
151
|
+
// alone — the workflow row is never re-read after the run starts.
|
|
152
|
+
cell.subagentModel = over.subagentModel ?? cfg.subagentModel ?? '';
|
|
153
|
+
}
|
|
154
|
+
if (node.kind === 'and' || node.kind === 'or' || node.kind === 'combine') {
|
|
155
|
+
cell.arity = Number.isInteger(cfg.arity) ? cfg.arity : 2;
|
|
156
|
+
}
|
|
157
|
+
return cell;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const manifestWires = wires.map((w) => {
|
|
161
|
+
const loop = loops.loopWireIds.has(w.id);
|
|
162
|
+
const cell = { id: w.id, from: { node: w.from.node, port: w.from.port },
|
|
163
|
+
to: { node: w.to.node, port: w.to.port }, loop };
|
|
164
|
+
// maxCycles rides LOOP wires only: overlay > authored > default.
|
|
165
|
+
if (loop) cell.maxCycles = coerceCycles(wireOverlays[w.id]?.maxCycles ?? w.config?.maxCycles);
|
|
166
|
+
return cell;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── the v1 shim (P4-P7; deleted in P8) ─────────────────────────────────────
|
|
170
|
+
const byRank = new Map();
|
|
171
|
+
for (const node of manifestNodes) {
|
|
172
|
+
const r = ranks[node.id] ?? 0;
|
|
173
|
+
if (!byRank.has(r)) byRank.set(r, []);
|
|
174
|
+
byRank.get(r).push(node);
|
|
175
|
+
}
|
|
176
|
+
const agentCells = [...byRank.keys()].sort((a, b) => a - b).map((r) => ({
|
|
177
|
+
kind: 'agents',
|
|
178
|
+
nodes: byRank.get(r)
|
|
179
|
+
.sort((a, b) => (launchIndex.get(a.id) ?? 0) - (launchIndex.get(b.id) ?? 0))
|
|
180
|
+
.map((n) => ({
|
|
181
|
+
id: n.id,
|
|
182
|
+
key: n.key,
|
|
183
|
+
uiPhase: n.uiPhase,
|
|
184
|
+
label: n.label,
|
|
185
|
+
color: n.color || '',
|
|
186
|
+
sub: (n.key && agentsByKey?.[n.key]?.description) || '',
|
|
187
|
+
cycles: n.ports.inputs.some((p) => p.loop && isWired.has(`${n.id}.${p.id}`)),
|
|
188
|
+
model: n.model || '',
|
|
189
|
+
effort: n.effort || '',
|
|
190
|
+
})),
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
version: 2,
|
|
195
|
+
template: { id: tpl?.id ?? '', name: tpl?.name ?? '' },
|
|
196
|
+
graph: { nodes: manifestNodes, wires: manifestWires },
|
|
197
|
+
bookends: { preflight: true, done: true },
|
|
198
|
+
steps: [
|
|
199
|
+
{ kind: 'preflight', nodes: [{ id: 'preflight', label: 'Preflight', sub: 'checks' }] },
|
|
200
|
+
...agentCells,
|
|
201
|
+
{ kind: 'done', nodes: [{ id: 'done', label: 'Done', sub: 'complete' }] },
|
|
202
|
+
],
|
|
203
|
+
feedbacks: manifestWires.filter((w) => w.loop)
|
|
204
|
+
.map((w) => ({ id: w.id, from: w.from.node, to: w.to.node, maxCycles: w.maxCycles })),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function coerceCycles(value) {
|
|
209
|
+
const n = Math.floor(Number(value));
|
|
210
|
+
return Number.isFinite(n) && n >= 1 ? n : DEFAULT_MAX_CYCLES;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A portsFn over a MANIFEST — the run monitor never touches the live registry.
|
|
214
|
+
* The await port is re-synthesized from the boolean so geometry, validation and
|
|
215
|
+
* hit-testing behave exactly as they do in the composer. */
|
|
216
|
+
export function manifestPortsFn(manifest) {
|
|
217
|
+
const byId = new Map((manifest?.graph?.nodes || []).map((n) => [n.id, n]));
|
|
218
|
+
return (node) => {
|
|
219
|
+
const cell = byId.get(node?.id);
|
|
220
|
+
if (!cell) return undefined;
|
|
221
|
+
return {
|
|
222
|
+
known: true,
|
|
223
|
+
ported: true,
|
|
224
|
+
inputs: cell.ports.await ? [...cell.ports.inputs, AWAIT_PORT] : [...cell.ports.inputs],
|
|
225
|
+
outputs: [...cell.ports.outputs],
|
|
226
|
+
displayName: cell.label,
|
|
227
|
+
color: cell.color,
|
|
228
|
+
icon: cell.icon,
|
|
229
|
+
// A verdict-bearing node is one with a conditional output — enough for V13
|
|
230
|
+
// and firedOutputs; the filename itself never leaves the engine.
|
|
231
|
+
verdict: cell.ports.outputs.some((p) => p.when && p.when !== 'always') ? { filename: '' } : undefined,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The renderable template inside a manifest (id/kind/x/y/config + wires). */
|
|
237
|
+
export function manifestTemplate(manifest) {
|
|
238
|
+
return {
|
|
239
|
+
id: manifest?.template?.id ?? '',
|
|
240
|
+
name: manifest?.template?.name ?? '',
|
|
241
|
+
version: TEMPLATE_VERSION,
|
|
242
|
+
domain: '',
|
|
243
|
+
nodes: (manifest?.graph?.nodes || []).map((n) => {
|
|
244
|
+
// `config` is restored VERBATIM from the cell — no per-key rebuild. The
|
|
245
|
+
// old `{arity, awaitAll}` reconstruction silently dropped everything else
|
|
246
|
+
// (`planStoreSeed` on the Task card of `wf_provided-plan`, for one).
|
|
247
|
+
const node = { id: n.id, kind: n.kind, x: n.x, y: n.y, config: { ...(n.config || {}) } };
|
|
248
|
+
if (n.kind === 'agent') node.key = n.key;
|
|
249
|
+
return node;
|
|
250
|
+
}),
|
|
251
|
+
wires: (manifest?.graph?.wires || []).map((w) => {
|
|
252
|
+
const wire = { id: w.id, from: { ...w.from }, to: { ...w.to } };
|
|
253
|
+
if (w.loop && w.maxCycles !== undefined) wire.config = { maxCycles: w.maxCycles };
|
|
254
|
+
return wire;
|
|
255
|
+
}),
|
|
256
|
+
};
|
|
257
|
+
}
|