@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.
- 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,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
|
+
}
|