@worca/app 1.0.0-rc.1 → 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.
Files changed (138) hide show
  1. package/README.md +22 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +319 -45
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,410 @@
1
+ // ui/public/graph/run-decor.mjs (depth 3 below the repo root)
2
+ //
3
+ // Run monitor v2. ONE pure reducer turns a run's `state` into ONE decor bag, and
4
+ // ONE DOM pass (applyDecor, Task 4) lays that bag over the shared graph view.
5
+ //
6
+ // Genericity charter: nothing here keys off an agent key or a phase name. Row
7
+ // labels read the manifest's port metadata (`loop: true`), statuses read the
8
+ // execution ledger (state.steps[], one row per execution, key === executionId),
9
+ // colours read the manifest node. History renders with the registry absent.
10
+ import { manifestPortsFn, manifestTemplate } from '../../../src/shared/graph/manifest.mjs';
11
+ import { BOOKEND_EXECUTION_IDS, DEFAULT_MAX_CYCLES } from '../../../src/shared/graph/constants.mjs';
12
+ import { fanLines } from '../../../src/shared/graph/geometry.mjs';
13
+
14
+ /** The run-level warning a run that drained without binding End carries. */
15
+ export const QUIESCENCE_WARNING = 'finished at quiescence — End not reached';
16
+
17
+ /** Composite (kind:'task') rows label from the task title; keep the card readable. */
18
+ const TITLE_MAX = 40;
19
+ /** Squares in a sub-agent fan strip before the count alone carries the tail.
20
+ * Wraps at FAN_PER_ROW (16), so the cap must stay ≤ 32 — style.css spells the
21
+ * two-line fan height (`.fan.f2`) and no taller. */
22
+ const SUB_SQUARE_CAP = 24;
23
+ /** Leds on the collapsed strip before the `N runs · $…` text alone carries the
24
+ * tail — 6 squares + the summary + the chevron is what the 220px card fits. */
25
+ const STRIP_LED_CAP = 6;
26
+ /** Run statuses that mean "nothing is running any more". */
27
+ const TERMINAL_RUN = new Set(['done', 'stopped', 'error', 'paused']);
28
+ /** Row statuses that mean "this execution is over". */
29
+ const TERMINAL_ROW = new Set(['done', 'error', 'stopped', 'paused']);
30
+ /** P8's bookend EXECUTIONS (`x:preflight:1` / `x:done:1`); never executions, never progress. */
31
+ const BOOKEND_EXECS = new Set(BOOKEND_EXECUTION_IDS);
32
+
33
+ // Re-exported so run-hosts has ONE import for the manifest readers.
34
+ export { manifestPortsFn, manifestTemplate };
35
+
36
+ // ── formatters ──────────────────────────────────────────────────────────────
37
+
38
+ /** `4s` / `2m 10s` / `1h 1m` — integer seconds FIRST, then the same shape as
39
+ * app.js `fmtDuration`, so a card and the header never spell one duration two ways. */
40
+ export function fmtDur(ms) {
41
+ const s = Math.max(0, Math.round((Number(ms) || 0) / 1000));
42
+ if (s < 60) return `${s}s`;
43
+ const m = Math.floor(s / 60);
44
+ if (m < 60) return `${m}m ${s % 60}s`;
45
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
46
+ }
47
+
48
+ /** `$0.42`, with a `<$0.01` floor so a real-but-tiny spend never reads as free. */
49
+ export function fmtUsd(n) {
50
+ const v = Number(n);
51
+ if (!Number.isFinite(v)) return '$0.00';
52
+ if (v > 0 && v < 0.01) return '<$0.01';
53
+ return `$${v.toFixed(2)}`;
54
+ }
55
+
56
+ // ── manifest readers ────────────────────────────────────────────────────────
57
+
58
+ export function isGraphManifest(stepper) {
59
+ return !!(stepper && stepper.version === 2 && stepper.graph && Array.isArray(stepper.graph.nodes));
60
+ }
61
+ export function manifestNodes(stepper) {
62
+ return isGraphManifest(stepper) ? stepper.graph.nodes.filter(Boolean) : [];
63
+ }
64
+ export function manifestWires(stepper) {
65
+ return isGraphManifest(stepper) && Array.isArray(stepper.graph.wires) ? stepper.graph.wires.filter(Boolean) : [];
66
+ }
67
+ /** The header registry the view reads (`agents[node.key]` -> tint, title, icon),
68
+ * built from the manifest's own agent nodes so History never needs the registry. */
69
+ export function manifestAgents(stepper) {
70
+ const out = {};
71
+ for (const n of manifestNodes(stepper)) {
72
+ if (n.kind === 'agent' && n.key && !out[n.key]) out[n.key] = { displayName: n.label || n.key, color: n.color || '', icon: n.icon || '' };
73
+ }
74
+ return out;
75
+ }
76
+ const nodeById = (stepper, id) => manifestNodes(stepper).find((n) => n.id === id) || null;
77
+
78
+ // ── the execution ledger (state.steps[]) ────────────────────────────────────
79
+
80
+ /** Ledger rows in arrival order, bookends dropped. An EXECUTION row ALWAYS
81
+ * carries `executionId` (=== key; graph/orchestrator `_execStep`). Today's
82
+ * framework bookends are run-harness `_recordStep('preflight'|'done', 0, …)`
83
+ * rows — `{ key:'preflight'|'done', phase, cycle:0 }`, NO nodeId, NO
84
+ * executionId — so the executionId guard drops them; P8's bookend EXECUTIONS
85
+ * (`x:preflight:1` / `x:done:1`) are dropped by the shared id set. */
86
+ export function ledgerRows(st) {
87
+ const out = [];
88
+ for (const s of Array.isArray(st && st.steps) ? st.steps : []) {
89
+ if (!s || !s.executionId) continue;
90
+ if (BOOKEND_EXECS.has(s.executionId)) continue;
91
+ out.push(s);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** nodeId -> rows, in arrival order. */
97
+ function rowsByNode(rows) {
98
+ const map = new Map();
99
+ for (const r of rows) {
100
+ if (r.nodeId == null) continue;
101
+ if (!map.has(r.nodeId)) map.set(r.nodeId, []);
102
+ map.get(r.nodeId).push(r);
103
+ }
104
+ return map;
105
+ }
106
+
107
+ /** A row's active ms, extended by the live clock while it is running. */
108
+ export function rowMs(row, now, live) {
109
+ if (!row) return 0;
110
+ const base = Number(row.activeMs) || 0;
111
+ if (!live || !row.runningSince) return base;
112
+ return base + Math.max(0, (now || Date.now()) - new Date(row.runningSince).getTime());
113
+ }
114
+
115
+ /**
116
+ * The status precedence. NOTE the deliberate deviation from spec §8, which
117
+ * checks `active` AFTER the error rule: here membership in `state.active`
118
+ * wins FIRST, so a node whose OLDER execution errored and whose newer one
119
+ * is in flight reads `active` (the error stays visible in that node's
120
+ * footer rows). `rows` are THIS node's ledger
121
+ * rows in arrival order; `ctx.active` is the Set of node ids in state.active
122
+ * (already filtered: an entry whose own row is terminal is dropped by the
123
+ * reducer); `ctx.stepByExec` maps executionId -> row; `ctx.resolved` means
124
+ * nothing runs any more.
125
+ */
126
+ export function statusOf(node, rows, ctx) {
127
+ const list = Array.isArray(rows) ? rows : [];
128
+ // 1. in flight. `state.active` is the scheduler's own word and can precede the
129
+ // node's first row (the parent exec `start` lands before `_execStep`).
130
+ if (ctx.active.has(node.id)) return 'active';
131
+ // 2. never fired. A DONE run means the scheduler drained without ever reaching
132
+ // this node — including End when endReached === false (quiescence). A
133
+ // stopped/error run stopped early, so its unreached nodes stay `pending`.
134
+ if (!list.length) return ctx.runStatus === 'done' ? 'skipped' : 'pending';
135
+ const last = list[list.length - 1];
136
+ // 3. only the ledger knows a pause/stop: a paused execution completes as `done`
137
+ // on the wire, so the exec status alone would read it as finished.
138
+ if (last.status === 'paused' || last.status === 'stopped') return last.status;
139
+ // 4. any error in the node's history sticks.
140
+ if (list.some((r) => r.status === 'error')) return 'error';
141
+ // 5. finished cleanly.
142
+ if (last.status === 'done') return 'done';
143
+ // 6. started, not in flight, no terminal transition.
144
+ return ctx.resolved ? 'stopped' : 'active';
145
+ }
146
+
147
+ // ── the reducer ─────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * ONE decor bag from ONE state snapshot. `st` is the SAME shape live
151
+ * (orchestrator `state` events) and frozen (`rowToState` off pipelines +
152
+ * pipeline_steps), so both surfaces call this with no adapter.
153
+ * @param {object} st { stepper, status, steps, active, endReached, result, warnings, wireDeliveries, tokens, gate }
154
+ * @param {{live?:boolean, now?:number, subsOf?:(nodeId:string)=>Array}} opts
155
+ */
156
+ export function decorFromState(st, { live = true, now = Date.now(), subsOf = null } = {}) {
157
+ const state = st || {};
158
+ const stepper = state.stepper || null;
159
+ const nodes = manifestNodes(stepper);
160
+ const wires = manifestWires(stepper);
161
+ const runStatus = String(state.status || '').toLowerCase();
162
+ const resolved = !live || TERMINAL_RUN.has(runStatus);
163
+ const rows = ledgerRows(state);
164
+ const grouped = rowsByNode(rows);
165
+ const stepByExec = new Map(rows.map((r) => [r.executionId, r]));
166
+ // A composite (fan-out) PARENT execution has NO ledger row of its own — only its
167
+ // kind:'task' slices do (`parentExecutionId`), and they carry the parent's
168
+ // trigger verbatim. The last slice stands in for the parent.
169
+ const rowFor = (a) => stepByExec.get(a.executionId)
170
+ || (grouped.get(a.nodeId) || []).filter((r) => r.parentExecutionId === a.executionId).pop() || null;
171
+ // `_execStep(ctx,'done')` emits its state snapshot BEFORE the scheduler's own
172
+ // `exec done` drops the execution from `active`, so one frame can name an
173
+ // execution whose OWN row is already terminal. The row wins.
174
+ const activeList = (Array.isArray(state.active) ? state.active : []).filter((a) => {
175
+ if (!a) return false;
176
+ const own = stepByExec.get(a.executionId);
177
+ return !own || !TERMINAL_ROW.has(own.status);
178
+ });
179
+ const activeSet = new Set(activeList.map((a) => a.nodeId));
180
+ const ctx = { active: activeSet, stepByExec, resolved, runStatus };
181
+
182
+ const status = {};
183
+ const colors = {};
184
+ for (const node of nodes) {
185
+ status[node.id] = statusOf(node, grouped.get(node.id) || [], ctx);
186
+ colors[node.id] = node.color || '';
187
+ }
188
+
189
+ // Progress = done AGENT nodes / AGENT nodes (D15: a number, never a bar).
190
+ const agents = nodes.filter((n) => n.kind === 'agent');
191
+ const progress = { done: agents.filter((n) => status[n.id] === 'done').length, total: agents.length };
192
+
193
+ // Active nodes, most recently started FIRST (the compact row and the pill name
194
+ // the newest one; two or more collapse to "N agents running").
195
+ const startedAt = (a) => {
196
+ const r = rowFor(a);
197
+ const t = r && r.startedAt ? Date.parse(r.startedAt) : NaN;
198
+ return Number.isFinite(t) ? t : 0;
199
+ };
200
+ const activeNodes = activeList
201
+ .map((a) => {
202
+ const node = nodeById(stepper, a.nodeId);
203
+ return { nodeId: a.nodeId, executionId: a.executionId || null,
204
+ label: (node && (node.label || node.id)) || a.nodeId, color: (node && node.color) || '', _t: startedAt(a) };
205
+ })
206
+ .sort((x, y) => y._t - x._t)
207
+ .map(({ _t, ...a }) => a);
208
+
209
+ const endReached = state.endReached === true;
210
+ const endNode = nodes.find((n) => n.kind === 'end') || null;
211
+ const endResult = endReached && state.result && endNode ? buildEndResult(endNode, state.result) : null;
212
+
213
+ const warnings = (Array.isArray(state.warnings) ? state.warnings : []).filter(Boolean);
214
+ // The engine's own definition: `finish('done') && !ended`. A paused, stopped or
215
+ // errored run also has endReached:false, and is NOT quiescence.
216
+ const quiescent = runStatus === 'done' && state.endReached === false;
217
+ if (quiescent && !warnings.includes(QUIESCENCE_WARNING)) warnings.push(QUIESCENCE_WARNING);
218
+
219
+ const deliveries = state.wireDeliveries && typeof state.wireDeliveries === 'object' ? state.wireDeliveries : {};
220
+ const loopDeliveries = wires.reduce((a, w) => a + (w.loop ? (Number(deliveries[w.id]) || 0) : 0), 0);
221
+
222
+ const decor = {
223
+ version: 2, live, resolved, runStatus, status, colors,
224
+ footers: {}, totals: {}, liveWireIds: [], loopBadges: {}, gate: null,
225
+ endResult, progress, activeNodes, warnings, quiescent,
226
+ executions: rows.length, loopDeliveries,
227
+ nodeIds: nodes.map((n) => n.id), wireIds: wires.map((w) => w.id), expanded: null,
228
+ };
229
+ decorateExecutions(decor, { stepper, nodes, wires, grouped, rows, activeList, rowFor, stepByExec, state, now, live, subsOf });
230
+ return decor;
231
+ }
232
+
233
+ /** End's result chip: basename link for a path, the void treatment otherwise. */
234
+ function buildEndResult(endNode, result) {
235
+ const path = result && result.path ? String(result.path) : null;
236
+ const rel = path ? path.split('/').filter(Boolean).pop() : null;
237
+ return { nodeId: endNode.id, path, rel, text: path ? rel : '— completed', kind: path ? 'path' : 'void' };
238
+ }
239
+
240
+ /** `cycle 2 · fix` (loop-port re-fire) / `cycle 2` / the task title for slices. */
241
+ export function rowLabel(node, row) {
242
+ if (row.kind === 'task') {
243
+ const t = String(row.title || '').trim();
244
+ return truncate(t || `cycle ${row.ordinal}`);
245
+ }
246
+ const base = `cycle ${row.ordinal}`;
247
+ const loopIns = new Set(((node && node.ports && node.ports.inputs) || []).filter((p) => p && p.loop).map((p) => p.id));
248
+ const port = ((row.trigger && row.trigger.freshPorts) || []).find((p) => loopIns.has(p));
249
+ return port ? `${base} · ${port}` : base;
250
+ }
251
+
252
+ function truncate(s) {
253
+ return s.length > TITLE_MAX ? `${s.slice(0, TITLE_MAX - 1)}…` : s;
254
+ }
255
+
256
+ /** Money is CENTS: `0.1 + 0.32 === 0.42000000000000004` in doubles, and the raw
257
+ * `costUsd` number is part of the frozen bag shape, so it is rounded here once. */
258
+ const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
259
+ const sumUsd = (rows) => round2(rows.reduce((a, r) => a + (Number(r.costUsd) || 0), 0));
260
+
261
+ /** `3 runs · $1.12`; the cost half is dropped when the total is zero. */
262
+ export function stripText(rows) {
263
+ const list = Array.isArray(rows) ? rows : [];
264
+ if (!list.length) return '';
265
+ const total = sumUsd(list);
266
+ const runs = `${list.length} ${list.length === 1 ? 'run' : 'runs'}`;
267
+ return total > 0 ? `${runs} · ${fmtUsd(total)}` : runs;
268
+ }
269
+
270
+ /** The status the 7px leds / row classes use ('start' is the wire's word for active). */
271
+ const ledOf = (status) => (status === 'start' ? 'active' : (status || 'pending'));
272
+
273
+ /**
274
+ * How many 22px lines an exec row takes, decided from its texts so the model
275
+ * bills the exact height style.css renders (the card height is model-driven;
276
+ * CSS can never measure for it). Conservative per-glyph estimates — a wide
277
+ * outlier degrades to the ellipsis, never to a taller row:
278
+ * 1 = label + `dur · cost` share one line (the short-cycle common case);
279
+ * 2 = `dur · cost` moves to its own right-aligned line (`.stack`);
280
+ * 3 = the label itself takes two clamped lines above it (`.stack.l2`).
281
+ */
282
+ export function execBandLayout(label, right) {
283
+ const l = String(label || '');
284
+ const r = String(right || '');
285
+ if (!r) return { units: 1, stack: false, l2: false }; // flow rows: label alone
286
+ if (l.length * 5.8 + r.length * 6.1 <= 168) return { units: 1, stack: false, l2: false };
287
+ const l2 = l.length * 5.8 > 180; // past one 180px label line
288
+ return { units: l2 ? 3 : 2, stack: true, l2 };
289
+ }
290
+
291
+ function decorateExecutions(decor, ctx) {
292
+ const { nodes, wires, grouped, activeList, rowFor, state, now, live, subsOf } = ctx;
293
+
294
+ for (const node of nodes) {
295
+ const list = grouped.get(node.id) || [];
296
+ // A FLOW node (task/and/or/combine/end) executes instantly and for free: its
297
+ // rows carry no duration and no cost pill, and its card no header totals.
298
+ const flow = node.kind !== 'agent';
299
+ const rows = list.map((row) => {
300
+ // A flow node executes instantly and for free: the NUMBERS are zeroed with
301
+ // the pill texts, so `durMs`/`costUsd` never contradict `dur`/`cost` (a
302
+ // consumer that sums the numbers must not pick up a flow row's real ms).
303
+ const durMs = flow ? 0 : rowMs(row, now, live);
304
+ const costUsd = flow ? 0 : round2(row.costUsd);
305
+ return {
306
+ executionId: row.executionId, nodeId: node.id,
307
+ kind: row.kind === 'task' ? 'task' : 'cycle',
308
+ ordinal: Number(row.ordinal ?? row.cycle) || 1,
309
+ label: rowLabel(node, row), led: ledOf(row.status),
310
+ dur: !flow && row.activeMs != null ? fmtDur(durMs) : '',
311
+ cost: flow ? '' : fmtUsd(costUsd),
312
+ durMs, costUsd, flow,
313
+ };
314
+ });
315
+
316
+ const subs = typeof subsOf === 'function' ? (subsOf(node.id) || []) : [];
317
+ const fan = subs.length
318
+ ? { leds: subs.slice(0, SUB_SQUARE_CAP).map((s) => (s && s.status === 'running' ? 'run' : 'done')), count: subs.length }
319
+ : null;
320
+
321
+ if (rows.length || fan) {
322
+ decor.footers[node.id] = { rows, summary: stripText(rows), leds: rows.map((r) => r.led), fan };
323
+ }
324
+ if (rows.length && !flow) {
325
+ const durMs = rows.reduce((a, r) => a + r.durMs, 0);
326
+ const costUsd = sumUsd(rows);
327
+ decor.totals[node.id] = {
328
+ durMs, dur: fmtDur(durMs), costUsd, cost: fmtUsd(costUsd),
329
+ hasStep: rows.some((r) => r.dur !== ''),
330
+ };
331
+ }
332
+ }
333
+
334
+ // Ants: the trigger wires of the IN-FLIGHT executions (a composite parent
335
+ // marches on its slices' trigger — the slices carry the parent's verbatim).
336
+ // A resolved run marches nothing: a drain publish after End routes nowhere.
337
+ // Ants are NEVER restricted to loop wires — a re-fire through an OR valve
338
+ // names the valve's out wire; only the badges below are loop-wire-only.
339
+ if (live && !decor.resolved) {
340
+ const ants = new Set();
341
+ for (const a of activeList) {
342
+ const row = rowFor(a);
343
+ for (const id of (row && row.trigger && row.trigger.wireIds) || []) ants.add(id);
344
+ }
345
+ decor.liveWireIds = [...ants];
346
+ }
347
+
348
+ // Loop badges from the scheduler's own delivery counters (authoritative;
349
+ // `wireDeliveries` is keyed by LOOP wire ids only and counts tokens that went
350
+ // THROUGH the wire — a held token is not counted).
351
+ const deliveries = state.wireDeliveries && typeof state.wireDeliveries === 'object' ? state.wireDeliveries : {};
352
+ for (const w of wires) {
353
+ if (!w.loop) continue;
354
+ const n = Number(deliveries[w.id]) || 0;
355
+ if (n < 1) continue;
356
+ const max = Number(w.maxCycles) || DEFAULT_MAX_CYCLES;
357
+ decor.loopBadges[w.id] = { n, max, text: `${n}×`, title: `${n} of ${max} cycles` };
358
+ }
359
+
360
+ // Gate pip: the gate holds at the SOURCE's publish.
361
+ const g = state.gate && typeof state.gate === 'object' ? state.gate : null;
362
+ if (g && g.wireId) {
363
+ const wire = wires.find((w) => w.id === g.wireId) || null;
364
+ const nodeId = g.fromNode || (wire && wire.from && wire.from.node) || null;
365
+ if (nodeId) decor.gate = { nodeId, wireId: g.wireId, askId: g.askId || null };
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Lay the decor over an already-rendered view. Idempotent and self-clearing:
371
+ * every ornament is rebuilt from the bag, so a repaint after the run settles
372
+ * cannot strand an ant, a badge or a gate pip. NEVER writes a card height —
373
+ * `view.setFooter` re-runs `nodeSize` for the band count it was handed.
374
+ * Band order inside a footer, top → bottom: fan → strip → exec×N (only for the
375
+ * ONE expanded node) → result.
376
+ */
377
+ export function applyDecor(view, decor) {
378
+ if (!view || !decor) return;
379
+ const expanded = decor.expanded || null;
380
+ for (const nodeId of decor.nodeIds || []) {
381
+ view.setStatus(nodeId, decor.status[nodeId] || 'pending');
382
+ view.setNodeChrome(nodeId, {
383
+ color: decor.colors[nodeId] || '',
384
+ gate: decor.gate && decor.gate.nodeId === nodeId
385
+ ? { wireId: decor.gate.wireId, title: 'waiting on a loop gate — open the question panel' } : null,
386
+ totals: decor.totals[nodeId] || null,
387
+ });
388
+ const foot = decor.footers[nodeId] || null;
389
+ const bands = [];
390
+ if (foot && foot.fan) {
391
+ bands.push({ kind: 'fan', leds: foot.fan.leds, count: foot.fan.count, lines: fanLines(foot.fan.leds.length) });
392
+ }
393
+ if (foot && foot.rows.length) {
394
+ bands.push({ kind: 'strip', leds: foot.leds.slice(0, STRIP_LED_CAP), summary: foot.summary, expanded: expanded === nodeId });
395
+ if (expanded === nodeId) {
396
+ for (const r of foot.rows) {
397
+ const right = [r.dur, r.cost].filter(Boolean).join(' · ');
398
+ bands.push({ kind: 'exec', executionId: r.executionId, led: r.led, label: r.label,
399
+ right, ...execBandLayout(r.label, right) });
400
+ }
401
+ }
402
+ }
403
+ if (decor.endResult && decor.endResult.nodeId === nodeId) {
404
+ bands.push({ kind: 'result', text: decor.endResult.text, path: decor.endResult.path });
405
+ }
406
+ view.setFooter(nodeId, bands);
407
+ }
408
+ for (const wireId of decor.wireIds || []) view.setWireBadge(wireId, decor.loopBadges[wireId] || null);
409
+ view.setWireLive(decor.liveWireIds || []);
410
+ }
@@ -0,0 +1,201 @@
1
+ // ui/public/graph/run-hosts.mjs (depth 3 below the repo root)
2
+ //
3
+ // The three run-graph HOSTS. One renderer (view.mjs) + one decor pass
4
+ // (run-decor.mjs) mounted with per-host mode, zoom clamp, wheel policy and
5
+ // sizing. Nothing here knows about the app's run model — app.js hands it a
6
+ // manifest + a decor bag and gets clicks back.
7
+ //
8
+ // Measurement: `viewport` is a FUNCTION `() => ({left, top, width, height})`
9
+ // (or null), handed VERBATIM to createGraphView; every size this module needs
10
+ // is `view.readRect()` — the ONE measurement path the renderer already owns.
11
+ // Bounds: `view.bounds(pad)` — the shared geometry over the RENDERED template
12
+ // with the footer rows the view actually painted (never a second band count).
13
+ import { createGraphView } from './view.mjs';
14
+ import { applyDecor, manifestAgents, manifestNodes, manifestPortsFn, manifestTemplate } from './run-decor.mjs';
15
+ import { fitBounds } from '../../../src/shared/graph/geometry.mjs';
16
+
17
+ export const STATIC_HOST_H = 300; // D5: the Running list card's graph height
18
+ export const STATIC_INSET = 32; // wrap padding allowance on both axes (16 each side)
19
+ export const DETAIL_MIN_H = 360, DETAIL_MAX_H = 600, DETAIL_PAD_H = 48;
20
+ export const HINT_TEXT = 'click to pan · ⌘+scroll to zoom';
21
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
22
+
23
+ /** Centre `b` (already padded) in a vw×vh viewport through the SHARED `fitBounds`;
24
+ * fit NEVER magnifies past 1× (spec §7.6). Returns the view's `{x, y, z}`. */
25
+ export function fitInto(b, vw, vh, { zoomMin = 0.3 } = {}) {
26
+ const f = fitBounds(b, { width: vw, height: vh }, { zoomMin, zoomMax: 1 });
27
+ return { x: f.tx, y: f.ty, z: f.z };
28
+ }
29
+
30
+ /** The node-id set as a string — a change of it is the ONE structural change
31
+ * short of a new run (the manifest is never rewritten mid-run, spec §5.8). */
32
+ const nodeSig = (stepper) => manifestNodes(stepper).map((n) => n.id).join(',');
33
+
34
+ export function mountRunGraph(hostEl, opts = {}) {
35
+ const { mode = 'monitor', doc = hostEl.ownerDocument, raf = null, viewport = null,
36
+ onRowClick = null, onGateClick = null, onResultClick = null } = opts;
37
+ const wrap = hostEl.closest('.run-flow-wrap') || hostEl.parentElement || hostEl;
38
+ const win = doc.defaultView || globalThis;
39
+ const isStatic = mode === 'static';
40
+ const zoomMin = 0.3, zoomMax = isStatic ? 1 : 1.6;
41
+
42
+ let view = null, stepper = null, decor = null, runId = null, expanded = null, ro = null, lastFit = null, bound = false;
43
+ // The last width `view.readRect()` reported. A host inside a `display:none`
44
+ // subtree (compact density, a closed detail) measures 0×0, and both fitters
45
+ // BAIL on that rather than poison the transform / --run-host-h with it; the
46
+ // first paint that sees a real box has to re-fit even when nothing structural
47
+ // changed, or the graph keeps the transform it had while it was hidden.
48
+ let lastRectW = 0;
49
+ const listeners = [];
50
+ const on = (target, type, fn, o) => { target.addEventListener(type, fn, o); listeners.push([target, type, fn, o]); };
51
+
52
+ /** True until the user pans or zooms away from the last auto-fit. */
53
+ const untouched = () => {
54
+ if (!view || !lastFit) return true;
55
+ const t = view.getTransform();
56
+ return Math.abs(t.x - lastFit.x) < 0.5 && Math.abs(t.y - lastFit.y) < 0.5 && Math.abs(t.z - lastFit.z) < 1e-6;
57
+ };
58
+
59
+ // Static (the Running list card): fit BOTH axes into (width − 32, 300 − 32),
60
+ // 0.3–1×. CSS scrollable overflow only extends rightwards, so a graph that is
61
+ // wider than the card even at the 0.3 floor is LEFT-aligned and the host is
62
+ // widened inline; `.gv-wrap-static{overflow-x:auto}` then scrolls it natively.
63
+ function fitStatic() {
64
+ // FIRST, before ANY measurement: `.gv-stage` is `inset:0` inside
65
+ // `.run-flow.gv-host`, so `view.readRect()` returns the HOST's box — including
66
+ // the inline width this very function writes on the wide branch. Leaving it on
67
+ // would make the next fit measure the widened host, take the centred branch,
68
+ // clear the width, and take the wide branch again: consecutive fits oscillate.
69
+ hostEl.style.width = '';
70
+ const r = view.readRect();
71
+ if (!(r.width > 0)) return; // hidden host (0×0): nothing to fit into
72
+ const b = view.bounds(16);
73
+ if (!b) return;
74
+ const vw = Math.max(1, r.width - STATIC_INSET), vh = STATIC_HOST_H - STATIC_INSET;
75
+ const f = fitInto(b, vw, vh, { zoomMin });
76
+ const sw = b.w * f.z;
77
+ if (sw > vw) {
78
+ hostEl.style.width = `${Math.ceil(sw + STATIC_INSET)}px`;
79
+ view.setTransform({ x: 16 - b.x * f.z, y: 16 + f.y, z: f.z });
80
+ } else {
81
+ hostEl.style.width = '';
82
+ view.setTransform({ x: 16 + f.x, y: 16 + f.y, z: f.z });
83
+ }
84
+ }
85
+
86
+ // Monitor (the detail pages), two-pass: the WIDTH decides the zoom, the zoom
87
+ // decides the host height (clamp 360..600 via --run-host-h on the wrap), then
88
+ // both axes fit into (width, hostH). `applyTransform: false` is the height
89
+ // pass alone — a touched view keeps its pan/zoom while a taller card lands.
90
+ function fitMonitor(applyTransform) {
91
+ const r = view.readRect();
92
+ if (!(r.width > 0)) return; // hidden host (0×0): a 0-width fit would
93
+ const b = view.bounds(24); // pin --run-host-h at the 360px floor and
94
+ if (!b) return; // zoom to the 0.3 clamp for good.
95
+ const vw = Math.max(1, r.width);
96
+ const zw = clamp(Math.min(vw / b.w, 1), zoomMin, 1);
97
+ const hostH = clamp(Math.round((b.h - 48) * zw + DETAIL_PAD_H), DETAIL_MIN_H, DETAIL_MAX_H); // b is padded 24 each side
98
+ wrap.style.setProperty('--run-host-h', `${hostH}px`);
99
+ if (applyTransform) view.setTransform(fitInto(b, vw, hostH, { zoomMin }));
100
+ }
101
+
102
+ function fit() {
103
+ if (!view || !stepper) return;
104
+ if (isStatic) fitStatic(); else fitMonitor(true);
105
+ lastFit = view.getTransform();
106
+ }
107
+ /** The size pass without the transform (monitor only; static hosts never grow). */
108
+ function sizeHost() {
109
+ if (!view || !stepper || isStatic) return;
110
+ fitMonitor(false);
111
+ }
112
+ /** Re-fit while the user has not touched the view; otherwise only re-size the host. */
113
+ function refit() { if (untouched()) fit(); else sizeHost(); }
114
+
115
+ function paint() {
116
+ if (!view || !decor) return;
117
+ applyDecor(view, { ...decor, expanded });
118
+ }
119
+
120
+ function mount() {
121
+ // `.run-flow` is the v1 flex column box (padding 66/52, width:max-content).
122
+ // The v2 renderer's `.gv-stage{position:absolute;inset:0}` would fill THAT
123
+ // padding box, not the wrap — `.gv-host` / `.gv-wrap-*` (style.css) reset it.
124
+ hostEl.classList.add('gv-host');
125
+ wrap.classList.add('gv-wrap', isStatic ? 'gv-wrap-static' : 'gv-wrap-monitor');
126
+ view = createGraphView(hostEl, { mode, doc, raf, viewport,
127
+ portsFn: manifestPortsFn(stepper), agents: manifestAgents(stepper), zoomMin, zoomMax,
128
+ wheelPan: isStatic ? 'always' : 'engaged' });
129
+ // The view never auto-binds a nav: monitor hosts ask for one (D8 engaged-only)
130
+ // and learn engagement from it — run-hosts keeps NO engagement state of its own,
131
+ // and view.destroy() destroys every nav, so the handle is not kept.
132
+ if (!isStatic) view.createNav({ wheelPan: 'engaged', onEngaged: (engaged) => wrap.classList.toggle('rg-engaged', engaged) });
133
+ }
134
+
135
+ function update(nextRunId, nextStepper, nextDecor) {
136
+ const runChanged = nextRunId !== runId;
137
+ const structural = !view || runChanged || nodeSig(nextStepper) !== nodeSig(stepper);
138
+ const sameBag = !structural && nextDecor === decor;
139
+ if (runChanged) { runId = nextRunId; expanded = null; lastFit = null; } // one node open per surface; a new run is a new build
140
+ // A node-set change needs a view whose portsFn/headers read the NEW manifest.
141
+ if (view && structural && nodeSig(nextStepper) !== nodeSig(stepper)) { view.destroy(); view = null; lastFit = null; }
142
+ stepper = nextStepper; decor = nextDecor;
143
+ if (!view) mount();
144
+ if (!bound) { bind(); bound = true; }
145
+ if (structural) view.render(manifestTemplate(stepper), {}); // the O(n) wire rewrite: structural only
146
+ if (!sameBag) paint(); // statuses/footers/badges: fast paths
147
+ // A host that was hidden (0×0) when it was last painted never got a real fit:
148
+ // re-fit on the first paint that sees a box, structural or not.
149
+ const rectW = view.readRect().width || 0;
150
+ const revealed = rectW > 0 && !(lastRectW > 0);
151
+ lastRectW = rectW;
152
+ if ((structural || revealed) && untouched()) fit();
153
+ }
154
+
155
+ function bind() {
156
+ if (!isStatic) {
157
+ const hint = doc.createElement('div');
158
+ hint.className = 'rg-hint';
159
+ hint.textContent = HINT_TEXT;
160
+ wrap.appendChild(hint);
161
+ // (engage/disengage listeners live in the view's nav — see mount() above)
162
+ on(hostEl, 'click', (e) => {
163
+ const toggle = e.target.closest && e.target.closest('.xtoggle');
164
+ if (toggle) { expanded = expanded === toggle.dataset.nodeId ? null : toggle.dataset.nodeId; paint(); refit(); return; }
165
+ const link = e.target.closest && e.target.closest('.xresult a');
166
+ if (link) { e.preventDefault(); if (onResultClick) onResultClick(link.dataset.path); return; }
167
+ const gate = e.target.closest && e.target.closest('.ngate');
168
+ if (gate) { if (onGateClick) onGateClick(gate.dataset.wireId); return; }
169
+ const row = e.target.closest && e.target.closest('.xrow');
170
+ if (row && onRowClick) onRowClick(row.dataset.executionId, row.dataset.nodeId);
171
+ });
172
+ }
173
+ // jsdom has no ResizeObserver — guard through the document's window (P5's idiom).
174
+ if (typeof win.ResizeObserver === 'function') {
175
+ ro = new win.ResizeObserver(() => { if (isStatic) fit(); else refit(); });
176
+ ro.observe(wrap);
177
+ }
178
+ }
179
+
180
+ function destroy() {
181
+ for (const [t, type, fn, o] of listeners) t.removeEventListener(type, fn, o);
182
+ listeners.length = 0;
183
+ if (ro) { try { ro.disconnect(); } catch { /* jsdom */ } ro = null; }
184
+ const hint = wrap.querySelector(':scope > .rg-hint');
185
+ if (hint) hint.remove();
186
+ if (view) { view.destroy(); view = null; } // view.destroy() tears every nav down
187
+ hostEl.classList.remove('gv-host');
188
+ wrap.classList.remove('gv-wrap', 'gv-wrap-static', 'gv-wrap-monitor', 'rg-engaged');
189
+ wrap.style.removeProperty('--run-host-h');
190
+ hostEl.style.width = '';
191
+ hostEl.innerHTML = '';
192
+ lastFit = null;
193
+ lastRectW = 0;
194
+ // bind() is called once per mount, guarded by `bound`. destroy() removed every
195
+ // listener, the hint chip and the ResizeObserver, so a later update() must be
196
+ // allowed to bind them again — otherwise the re-mounted view is inert.
197
+ bound = false;
198
+ }
199
+
200
+ return { update, fit, destroy, get view() { return view; } };
201
+ }
@@ -0,0 +1,56 @@
1
+ // ui/public/graph/save-dialog.mjs
2
+ // The composer's save modal — a real <dialog>, never window.prompt. Pure
3
+ // renderer: it returns a DETACHED dialog; composer.mjs mounts it and owns the POST.
4
+ function h(doc, tag, cls, text) { const n = doc.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
5
+
6
+ export function renderSaveDialog({ name = '', domain = '', domains = [], title = 'Save pipeline', note = '', doc = globalThis.document } = {}) {
7
+ const dlg = doc.createElement('dialog');
8
+ dlg.className = 'save-dialog';
9
+ const form = h(doc, 'div', 'sd-body');
10
+ form.appendChild(h(doc, 'h2', 'sd-title', title));
11
+ // `note` is standing provenance ("this row belongs to a plugin"), NOT an
12
+ // error: it sits above the fields and never shares the .sd-msg line, which
13
+ // the server's 422/409 refusals own.
14
+ if (note) form.appendChild(h(doc, 'p', 'sd-note', note));
15
+
16
+ const nameWrap = h(doc, 'div', 'sd-field');
17
+ nameWrap.appendChild(h(doc, 'label', null, 'Name'));
18
+ const nameInput = doc.createElement('input');
19
+ nameInput.type = 'text'; nameInput.className = 'sd-name'; nameInput.value = name;
20
+ nameInput.setAttribute('spellcheck', 'false'); nameInput.placeholder = 'e.g. Full pipeline';
21
+ nameWrap.appendChild(nameInput);
22
+ form.appendChild(nameWrap);
23
+
24
+ const domWrap = h(doc, 'div', 'sd-field');
25
+ domWrap.appendChild(h(doc, 'label', null, 'Domain'));
26
+ const domInput = doc.createElement('input');
27
+ domInput.type = 'text'; domInput.className = 'sd-domain'; domInput.value = domain;
28
+ domInput.setAttribute('spellcheck', 'false'); domInput.placeholder = 'general';
29
+ const listId = `sd-domains-${Math.random().toString(36).slice(2, 8)}`;
30
+ domInput.setAttribute('list', listId);
31
+ const list = doc.createElement('datalist');
32
+ list.id = listId;
33
+ for (const d of domains) { const o = doc.createElement('option'); o.value = d; list.appendChild(o); }
34
+ domWrap.append(domInput, list);
35
+ form.appendChild(domWrap);
36
+
37
+ form.appendChild(h(doc, 'p', 'sd-msg'));
38
+ const actions = h(doc, 'div', 'sd-actions');
39
+ const cancel = h(doc, 'button', 'btn-ghost sd-cancel', 'Cancel'); cancel.type = 'button';
40
+ const confirm = h(doc, 'button', 'btn-go sd-confirm', 'Save'); confirm.type = 'button';
41
+ actions.append(cancel, confirm);
42
+ form.appendChild(actions);
43
+ dlg.appendChild(form);
44
+ return dlg;
45
+ }
46
+
47
+ /** jsdom (and older browsers) lack showModal — fall back to the `open`
48
+ * attribute so the modal is still in the accessibility tree. */
49
+ export function openDialog(dlg) {
50
+ if (typeof dlg.showModal === 'function') { try { dlg.showModal(); return; } catch { /* already open */ } }
51
+ dlg.setAttribute('open', '');
52
+ }
53
+ export function closeDialog(dlg) {
54
+ if (typeof dlg.close === 'function') { try { dlg.close(); return; } catch { /* not open */ } }
55
+ dlg.removeAttribute('open');
56
+ }