@giamme/forge 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/* forge-ui run model: pure data for the 3D stage, the 2D views and the inspector. No DOM, no I/O.
|
|
2
|
+
A plain script in the page (window.ForgeRunModel) and a CommonJS module under node:test.
|
|
3
|
+
plate() is archivision's lib/layout.js (MIT, © 2026 Giamme), with the size function passed in so run
|
|
4
|
+
blocks and task blocks can size themselves. */
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
'use strict';
|
|
7
|
+
var api = factory();
|
|
8
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
9
|
+
else root.ForgeRunModel = api;
|
|
10
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
14
|
+
const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
15
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
16
|
+
const DONE = new Set(['MERGED', 'PASS', 'NOCHANGES']);
|
|
17
|
+
const BAD = new Set(['FAIL', 'ERROR', 'CONFLICT', 'INFRA', 'TIMEOUT', 'INVALIDATED', 'INTERRUPTED', 'UNKNOWN', 'BLOCKED']);
|
|
18
|
+
const WEIGHT = { low: 1, medium: 2, high: 3 };
|
|
19
|
+
const weight = t => WEIGHT[String(t && t.difficulty || '').toLowerCase()] || 2;
|
|
20
|
+
const alias = spec => spec ? String(spec).split(':')[0] : null;
|
|
21
|
+
const basename = p => p ? String(p).split(/[\\/]/).filter(Boolean).pop() || String(p) : '—';
|
|
22
|
+
function fmtDur(s) {
|
|
23
|
+
s = num(s); if (s === null) return '—';
|
|
24
|
+
s = Math.max(0, Math.round(s));
|
|
25
|
+
if (s < 60) return s + 's';
|
|
26
|
+
if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
|
|
27
|
+
return Math.floor(s / 3600) + 'h' + String(Math.floor(s % 3600 / 60)).padStart(2, '0') + 'm';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* ---------- archivision's plate: one district per layer, wrapped into rows ---------- */
|
|
31
|
+
const GAPX = 18, PADX = 22, TOPPAD = 44, BOTPAD = 16, ROWGAP = 18, DGAP = 18;
|
|
32
|
+
// modules: [{ id, layer, … }] in display order; layers: [{ id }] in display order.
|
|
33
|
+
function plate({ modules, layers }, size) {
|
|
34
|
+
const used = layers.filter(l => modules.some(m => m.layer === l.id));
|
|
35
|
+
if (!modules.length) return { px: 900, py: 400, place: {}, districts: [], rows: 0 };
|
|
36
|
+
const AREA = Math.sqrt(modules.reduce((t, m) => { const [w, d] = size(m); return t + (w + GAPX) * (d + TOPPAD + BOTPAD + ROWGAP); }, 0));
|
|
37
|
+
const PXT = Math.max(2 * PADX + Math.max(...modules.map(m => size(m)[0])),
|
|
38
|
+
Math.round((AREA * 1.12 >= 1600 ? AREA * 1.12 : Math.max(900, Math.min(1600, AREA * 1.35))) / 10) * 10);
|
|
39
|
+
const dists = [];
|
|
40
|
+
for (const l of used) {
|
|
41
|
+
let chunk = [], cw = 2 * PADX - GAPX;
|
|
42
|
+
for (const m of modules.filter(x => x.layer === l.id)) {
|
|
43
|
+
const [w] = size(m);
|
|
44
|
+
if (chunk.length && cw + GAPX + w > PXT) { dists.push({ layer: l.id, mods: chunk }); chunk = []; cw = 2 * PADX - GAPX; }
|
|
45
|
+
chunk.push(m); cw += GAPX + w;
|
|
46
|
+
}
|
|
47
|
+
if (chunk.length) dists.push({ layer: l.id, mods: chunk });
|
|
48
|
+
}
|
|
49
|
+
const rows = []; let row = [], rx = 0;
|
|
50
|
+
for (const d of dists) {
|
|
51
|
+
d.w = 2 * PADX + d.mods.reduce((a, m) => a + size(m)[0], 0) + GAPX * (d.mods.length - 1);
|
|
52
|
+
d.d = TOPPAD + Math.max(...d.mods.map(m => size(m)[1])) + BOTPAD;
|
|
53
|
+
if (row.length && rx + DGAP + d.w > PXT) { rows.push(row); row = []; rx = 0; }
|
|
54
|
+
row.push(d); rx += (row.length > 1 ? DGAP : 0) + d.w;
|
|
55
|
+
}
|
|
56
|
+
if (row.length) rows.push(row);
|
|
57
|
+
const PX = Math.max(PXT, ...rows.map(r => r.reduce((a, d) => a + d.w, 0) + DGAP * (r.length - 1)));
|
|
58
|
+
const place = {}, districts = [];
|
|
59
|
+
let y = 0;
|
|
60
|
+
rows.forEach(r => {
|
|
61
|
+
const w0 = r.reduce((a, d) => a + d.w, 0) + DGAP * (r.length - 1), extra = PX - w0, dmax = Math.max(...r.map(d => d.d));
|
|
62
|
+
let x = 0;
|
|
63
|
+
r.forEach((d, i) => {
|
|
64
|
+
const w = d.w + (i === r.length - 1 ? extra : 0);
|
|
65
|
+
districts.push({ layer: d.layer, x, y, w, d: dmax });
|
|
66
|
+
let bx = x + PADX;
|
|
67
|
+
for (const m of d.mods) { const [bw, bd, bh] = size(m); place[m.id] = [bx, y + TOPPAD, bw, bd, Math.round(bh)]; bx += bw + GAPX; }
|
|
68
|
+
x += w + DGAP;
|
|
69
|
+
});
|
|
70
|
+
y += dmax + ROWGAP;
|
|
71
|
+
});
|
|
72
|
+
return { px: PX, py: y - ROWGAP, place, districts, rows: rows.length };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---------- statuses ---------- */
|
|
76
|
+
function runState(run) {
|
|
77
|
+
const counts = run && run.counts || {};
|
|
78
|
+
if (counts.RUNNING) return 'live';
|
|
79
|
+
if (Object.keys(counts).some(s => BAD.has(s) && counts[s] > 0)) return 'failed';
|
|
80
|
+
const total = Object.values(counts).reduce((a, n) => a + (n || 0), 0);
|
|
81
|
+
if (total && Object.keys(counts).every(s => DONE.has(s) || !counts[s])) return 'done';
|
|
82
|
+
return 'pending';
|
|
83
|
+
}
|
|
84
|
+
// What one dwarf/qa/task execution ended as, in the task status vocabulary.
|
|
85
|
+
function execStatus(e) {
|
|
86
|
+
if (!e) return 'PENDING';
|
|
87
|
+
if (e.state === 'running') return 'RUNNING';
|
|
88
|
+
if (e.state === 'orphaned') return 'INTERRUPTED';
|
|
89
|
+
if (e.timed_out) return 'TIMEOUT';
|
|
90
|
+
if (e.infra_class) return 'INFRA';
|
|
91
|
+
if (e.role === 'qa' && (e.verdict === 'PASS' || e.verdict === 'FAIL')) return e.verdict;
|
|
92
|
+
if (e.exit_code === 0) return e.role === 'qa' ? 'UNKNOWN' : 'PASS';
|
|
93
|
+
return 'ERROR';
|
|
94
|
+
}
|
|
95
|
+
// The clock a run is drawn at: now, unless the run has not changed for longer than any dispatch may take
|
|
96
|
+
// (twice its timeout), in which case what still says RUNNING died with its process and stops at that point.
|
|
97
|
+
function liveNow(run, now) {
|
|
98
|
+
const t = num(now); if (t === null) return null;
|
|
99
|
+
const upd = num(run && run.updated); if (upd === null) return t;
|
|
100
|
+
const timeout = num(run && run.settings && run.settings.timeout) || 3600;
|
|
101
|
+
return Math.min(t, upd + 2 * Math.max(timeout, 600));
|
|
102
|
+
}
|
|
103
|
+
const execEnd = (e, now) => num(e.ended) ?? (e.state === 'running' && num(now) !== null ? Math.max(num(e.started) || now, now) : num(e.started));
|
|
104
|
+
|
|
105
|
+
/* ---------- filters and the atlas (every run on one plate) ---------- */
|
|
106
|
+
function matchRun(run, f, now) {
|
|
107
|
+
f = f || {};
|
|
108
|
+
const text = String(f.text || '').trim().toLowerCase();
|
|
109
|
+
if (text && ![run.goal, run.repo, run.run_id, run.path, ...list(run.models)].some(v => String(v || '').toLowerCase().includes(text))) return false;
|
|
110
|
+
if (f.state && runState(run) !== f.state) return false;
|
|
111
|
+
if (f.repo && run.repo !== f.repo) return false;
|
|
112
|
+
if (f.kind && run.kind !== f.kind) return false;
|
|
113
|
+
if (f.model && !list(run.models).some(m => alias(m) === f.model)) return false;
|
|
114
|
+
if (f.ageDays && num(now) !== null && !(num(run.updated) !== null && run.updated >= now - f.ageDays * 86400)) return false;
|
|
115
|
+
if (f.verdict) {
|
|
116
|
+
const v = run.final_review || run.verification_status || null;
|
|
117
|
+
if (f.verdict === 'none' ? v : v !== f.verdict) return false;
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
function filterOptions(runs) {
|
|
122
|
+
const repos = new Map(), models = new Set();
|
|
123
|
+
for (const r of list(runs)) {
|
|
124
|
+
if (r.repo && !repos.has(r.repo)) repos.set(r.repo, basename(r.repo));
|
|
125
|
+
for (const m of list(r.models)) if (alias(m)) models.add(alias(m));
|
|
126
|
+
}
|
|
127
|
+
return { repos: [...repos].map(([path, name]) => ({ path, name })), models: [...models].sort() };
|
|
128
|
+
}
|
|
129
|
+
const atlasSize = m => {
|
|
130
|
+
const n = Math.max(1, m.tasks.length);
|
|
131
|
+
return [clamp(170 + Math.min(24, n) * 9, 190, 400), 150, 36 + Math.min(26, n * 1.4)];
|
|
132
|
+
};
|
|
133
|
+
// The newest runs first; by default the last `limit` runs within `days` (never fewer than `floor` when there are any).
|
|
134
|
+
function atlasModel(runs, f, { now = null, showAll = false, limit = 60, days = 14, floor = 12 } = {}) {
|
|
135
|
+
const sorted = list(runs).slice().sort((a, b) => (b.updated || 0) - (a.updated || 0));
|
|
136
|
+
let shown = sorted;
|
|
137
|
+
if (!showAll) {
|
|
138
|
+
const recent = num(now) === null ? sorted : sorted.filter(r => (r.updated || 0) >= now - days * 86400);
|
|
139
|
+
shown = (recent.length >= Math.min(floor, sorted.length) ? recent : sorted.slice(0, floor)).slice(0, limit);
|
|
140
|
+
}
|
|
141
|
+
const layers = [], seen = new Set();
|
|
142
|
+
for (const r of shown) {
|
|
143
|
+
const id = 'repo:' + (r.repo || '?');
|
|
144
|
+
if (!seen.has(id)) { seen.add(id); layers.push({ id, name: basename(r.repo), repo: r.repo || null }); }
|
|
145
|
+
}
|
|
146
|
+
const modules = shown.map(r => ({ id: r.key, layer: 'repo:' + (r.repo || '?'), run: r, state: runState(r),
|
|
147
|
+
tasks: list(r.task_statuses).length ? list(r.task_statuses) : Object.entries(r.counts || {}).flatMap(([s, n]) => Array.from({ length: Math.min(n, 60) }, (_, i) => ({ id: s + i, status: s }))),
|
|
148
|
+
match: matchRun(r, f, now) }));
|
|
149
|
+
const p = plate({ modules, layers }, atlasSize);
|
|
150
|
+
return { layers, modules, ...p, hidden: sorted.length - shown.length, total: sorted.length, matches: modules.filter(m => m.match).length };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ---------- the plan: waves as districts, then the finish line ---------- */
|
|
154
|
+
// Column of every task: its wave, else one right of its deepest dependency (graph.js's rule).
|
|
155
|
+
function columns(tasks) {
|
|
156
|
+
const byId = new Map(list(tasks).map(t => [t.id, t])), col = new Map(), floating = [];
|
|
157
|
+
for (const t of list(tasks)) {
|
|
158
|
+
const w = Math.floor(Number(t.wave));
|
|
159
|
+
if (t.wave !== null && t.wave !== undefined && Number.isFinite(w) && w >= 1) col.set(t.id, w); else { col.set(t.id, 1); floating.push(t); }
|
|
160
|
+
}
|
|
161
|
+
for (let pass = 0; pass < floating.length + 1; pass++) {
|
|
162
|
+
let changed = false;
|
|
163
|
+
for (const t of floating) {
|
|
164
|
+
let deepest = 0;
|
|
165
|
+
for (const d of list(t.deps)) if (d !== t.id && byId.has(d)) deepest = Math.max(deepest, col.get(d));
|
|
166
|
+
const want = deepest ? deepest + 1 : 1;
|
|
167
|
+
if (want !== col.get(t.id)) { col.set(t.id, want); changed = true; }
|
|
168
|
+
}
|
|
169
|
+
if (!changed) break;
|
|
170
|
+
}
|
|
171
|
+
return { col, inferred: new Set(floating.map(t => t.id)) };
|
|
172
|
+
}
|
|
173
|
+
const TASK_H = { low: 42, medium: 54, high: 66 };
|
|
174
|
+
const planSize = m => m.kind === 'task' ? [230, 168, TASK_H[String(m.task.difficulty || '').toLowerCase()] || 50] : [210, 150, 46];
|
|
175
|
+
// Integration is running only while a task is; a finished run with a task that never merged is blocked there.
|
|
176
|
+
function integrationStatus(statuses) {
|
|
177
|
+
if (!statuses.length) return 'PENDING';
|
|
178
|
+
if (statuses.some(s => s === 'RUNNING')) return 'RUNNING';
|
|
179
|
+
if (statuses.every(s => s === 'MERGED')) return 'MERGED';
|
|
180
|
+
if (statuses.some(s => s === 'PENDING' || s === 'PASS')) return 'PENDING';
|
|
181
|
+
return 'BLOCKED';
|
|
182
|
+
}
|
|
183
|
+
function finishState(run) {
|
|
184
|
+
const fr = run.final_review, v = run.verification, ig = run.integration;
|
|
185
|
+
const tasks = list(run.tasks), merged = tasks.filter(t => t.status === 'MERGED').length;
|
|
186
|
+
return {
|
|
187
|
+
integration: { status: integrationStatus(tasks.map(t => t.status)),
|
|
188
|
+
merged, total: tasks.length, base: ig && ig.base || null, accepted: ig && ig.accepted || null },
|
|
189
|
+
verification: { status: v && v.status ? String(v.status).toUpperCase() : 'PENDING', command: v && v.command || null, runs: list(v && v.runs).length },
|
|
190
|
+
final: { status: !fr ? (run.settings && run.settings.final_qa ? 'PENDING' : 'NONE') : fr.state === 'running' ? 'RUNNING' : fr.state === 'infra' ? 'INFRA' : fr.verdict || 'UNKNOWN',
|
|
191
|
+
requested: !!(fr || run.settings && run.settings.final_qa), spec: fr && fr.spec || run.settings && run.settings.final_qa || null, findings: fr && fr.findings || null,
|
|
192
|
+
count: list(run.final_reviews).length },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function planModel(run) {
|
|
196
|
+
const tasks = list(run && run.tasks), solo = run && run.kind === 'solo';
|
|
197
|
+
const { col, inferred } = columns(tasks);
|
|
198
|
+
const used = [...new Set(tasks.map(t => col.get(t.id)))].sort((a, b) => a - b);
|
|
199
|
+
const layers = used.map(c => ({ id: 'w' + c, name: solo ? 'SOLO RUN' : 'WAVE ' + c + (tasks.some(t => col.get(t.id) === c && inferred.has(t.id)) && !list(run.waves).length ? ' · inferred' : ''), wave: c }));
|
|
200
|
+
const modules = tasks.map(t => ({ id: 't:' + t.id, kind: 'task', layer: 'w' + col.get(t.id), task: t }));
|
|
201
|
+
const flows = [];
|
|
202
|
+
const ids = new Set(tasks.map(t => t.id));
|
|
203
|
+
for (const t of tasks) for (const d of new Set(list(t.deps))) if (ids.has(d) && d !== t.id) flows.push({ from: 't:' + d, to: 't:' + t.id, kind: 'dep' });
|
|
204
|
+
if (!solo && tasks.length) {
|
|
205
|
+
layers.push({ id: 'finish', name: 'FINISH', finish: true });
|
|
206
|
+
for (const [id, name] of [['f:integration', 'Integration'], ['f:verification', 'Verification'], ['f:final', 'Final review']]) modules.push({ id, kind: 'finish', layer: 'finish', name });
|
|
207
|
+
const needed = new Set(tasks.flatMap(t => list(t.deps)));
|
|
208
|
+
for (const t of tasks) if (!needed.has(t.id)) flows.push({ from: 't:' + t.id, to: 'f:integration', kind: 'finish' });
|
|
209
|
+
flows.push({ from: 'f:integration', to: 'f:verification', kind: 'finish' }, { from: 'f:verification', to: 'f:final', kind: 'finish' });
|
|
210
|
+
}
|
|
211
|
+
const p = plate({ modules, layers }, planSize);
|
|
212
|
+
return { layers, modules, flows, ...p, finish: solo ? null : finishState(run) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ---------- inside a task: one zone per attempt, its dwarf, its review and the shadow reviews ---------- */
|
|
216
|
+
// shadow.status is written only when a shadow review ends (done, timeout, error, …): none means it still runs
|
|
217
|
+
const shadowStatus = s => s.verdict || (s.status == null ? 'RUNNING' : s.status === 'done' ? 'UNKNOWN' : s.status === 'timeout' || s.status === 'overtime' ? 'TIMEOUT' : 'ERROR');
|
|
218
|
+
function taskParts(task) {
|
|
219
|
+
const execs = list(task && task.executions);
|
|
220
|
+
const byAttempt = new Map();
|
|
221
|
+
const put = (a, p) => { const k = a == null ? 'other' : String(a); if (!byAttempt.has(k)) byAttempt.set(k, []); byAttempt.get(k).push(p); };
|
|
222
|
+
for (const e of execs) {
|
|
223
|
+
if (e.role !== 'dwarf' && e.role !== 'qa') continue;
|
|
224
|
+
put(e.attempt, { id: e.id, kind: e.role, exec: e, status: execStatus(e), label: e.role === 'dwarf' ? 'DWARF' : 'QA', spec: e.model && e.model.spec || null, attempt: e.attempt });
|
|
225
|
+
}
|
|
226
|
+
for (const s of list(task && task.shadow)) {
|
|
227
|
+
put(s.attempt, { id: 'shadow:' + s.attempt + '-' + s.k, kind: 'shadow', shadow: s, exec: s.execution ? { id: s.execution } : null,
|
|
228
|
+
status: shadowStatus(s), label: 'SHADOW', spec: s.spec || null, attempt: s.attempt });
|
|
229
|
+
}
|
|
230
|
+
if (!byAttempt.size) {
|
|
231
|
+
put(1, { id: 'plan:dwarf', kind: 'dwarf', planned: true, status: 'PENDING', label: 'DWARF', spec: task && task.dwarf || null, attempt: 1 });
|
|
232
|
+
put(1, { id: 'plan:qa', kind: 'qa', planned: true, status: 'PENDING', label: 'QA', spec: task && task.qa || null, attempt: 1 });
|
|
233
|
+
}
|
|
234
|
+
const keys = [...byAttempt.keys()].sort((a, b) => a === 'other' ? 1 : b === 'other' ? -1 : Number(a) - Number(b));
|
|
235
|
+
const order = { dwarf: 0, qa: 1, shadow: 2 };
|
|
236
|
+
const zones = [], parts = [], links = [];
|
|
237
|
+
for (const k of keys) {
|
|
238
|
+
const ps = byAttempt.get(k).sort((a, b) => order[a.kind] - order[b.kind] || (num(a.exec && a.exec.started) || 0) - (num(b.exec && b.exec.started) || 0));
|
|
239
|
+
zones.push({ name: k === 'other' ? 'OTHER' : 'ATTEMPT ' + k, attempt: k === 'other' ? null : Number(k), parts: ps.map(p => p.id) });
|
|
240
|
+
parts.push(...ps);
|
|
241
|
+
const dw = ps.filter(p => p.kind === 'dwarf').pop(), qa = ps.filter(p => p.kind === 'qa').pop();
|
|
242
|
+
if (dw && qa) links.push({ from: dw.id, to: qa.id, kind: 'flow' });
|
|
243
|
+
for (const sh of ps.filter(p => p.kind === 'shadow')) if (qa) links.push({ from: qa.id, to: sh.id, kind: 'shadow', agree: sh.shadow.agreement || 'unknown' });
|
|
244
|
+
}
|
|
245
|
+
// Between attempts: the review that failed attempt n hands over to the next dwarf, labelled with the
|
|
246
|
+
// models when that retry was escalated. Escalations are matched by time: their attempt column counts differently.
|
|
247
|
+
const byId = new Map(parts.map(p => [p.id, p]));
|
|
248
|
+
const handover = i => {
|
|
249
|
+
const prev = zones[i - 1].parts.map(id => byId.get(id)).filter(p => p.kind !== 'shadow'), next = zones[i].parts.map(id => byId.get(id));
|
|
250
|
+
return [prev[prev.length - 1], next.find(p => p.kind === 'dwarf') || next[0]];
|
|
251
|
+
};
|
|
252
|
+
const escalated = new Map();
|
|
253
|
+
for (const esc of list(task && task.escalations)) {
|
|
254
|
+
const at = num(esc.at); if (at === null) continue;
|
|
255
|
+
const z = zones.findIndex(z => z.attempt !== null && z.parts.some(id => { const e = byId.get(id).exec; return e && num(e.started) !== null && e.started >= at; }));
|
|
256
|
+
if (z > 0 && !escalated.has(z)) escalated.set(z, esc);
|
|
257
|
+
}
|
|
258
|
+
for (let i = 1; i < zones.length; i++) {
|
|
259
|
+
const [a, b] = handover(i), esc = escalated.get(i);
|
|
260
|
+
if (a && b) links.push(esc ? { from: a.id, to: b.id, kind: 'escalation', label: `${esc.from || '?'} → ${esc.to || '?'}` } : { from: a.id, to: b.id, kind: 'retry' });
|
|
261
|
+
}
|
|
262
|
+
return { zones, parts, links };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ---------- timings ---------- */
|
|
266
|
+
function taskTimes(task, run, now) {
|
|
267
|
+
const execs = list(task && task.executions).filter(e => e.role === 'task' || e.role === 'solo');
|
|
268
|
+
const all = list(task && task.executions);
|
|
269
|
+
const starts = (execs.length ? execs : all).map(e => num(e.started)).filter(v => v !== null);
|
|
270
|
+
const ends = (execs.length ? execs : all).map(e => execEnd(e, now)).filter(v => v !== null);
|
|
271
|
+
const first = starts.length ? Math.min(...starts) : null, last = ends.length ? Math.max(...ends) : null;
|
|
272
|
+
const model = all.filter(e => e.role === 'dwarf' || e.role === 'qa').reduce((a, e) => a + (num(e.phases && e.phases.model) || 0), 0);
|
|
273
|
+
const busy = execs.reduce((a, e) => { const s = num(e.started), x = execEnd(e, now); return a + (s !== null && x !== null ? x - s : 0); }, 0);
|
|
274
|
+
let ready = null;
|
|
275
|
+
if (run) {
|
|
276
|
+
const byId = new Map(list(run.tasks).map(t => [t.id, t]));
|
|
277
|
+
const deps = list(task.deps).map(d => byId.get(d)).filter(Boolean);
|
|
278
|
+
const at = deps.map(d => num(d.merged_at)).filter(v => v !== null);
|
|
279
|
+
ready = deps.length ? (at.length === deps.length ? Math.max(...at) : null) : num(run.timeline && run.timeline.started);
|
|
280
|
+
}
|
|
281
|
+
const tokens = all.reduce((a, e) => { const t = e.tokens || {}; return { input: a.input + (num(t.input) || 0), output: a.output + (num(t.output) || 0), cached: a.cached + (num(t.cached) || 0) }; }, { input: 0, output: 0, cached: 0 });
|
|
282
|
+
return { first, last, busy, model, overhead: Math.max(0, busy - model), wait: first !== null && ready !== null ? Math.max(0, first - ready) : null, ready, tokens };
|
|
283
|
+
}
|
|
284
|
+
function metricValue(task, metric, run, now) {
|
|
285
|
+
if (!task) return null;
|
|
286
|
+
if (metric === 'duration') { const t = taskTimes(task, null, now); return t.busy || null; }
|
|
287
|
+
if (metric === 'tokens') { const t = taskTimes(task, null, now).tokens; return t.input + t.output || null; }
|
|
288
|
+
if (metric === 'attempts') return num(task.attempt) || list(task.executions).filter(e => e.role === 'task').length || null;
|
|
289
|
+
if (metric === 'model') return alias(task.dwarf);
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ---------- projected schedule: forge-schedule.py's rules, run forward from now ---------- */
|
|
294
|
+
function normPath(p) {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const s of String(p).split('/')) { if (!s || s === '.') continue; if (s === '..' && out.length && out[out.length - 1] !== '..') out.pop(); else out.push(s); }
|
|
297
|
+
return (String(p).startsWith('/') ? '/' : '') + out.join('/') || '.';
|
|
298
|
+
}
|
|
299
|
+
function overlap(a, b) {
|
|
300
|
+
const A = list(a).filter(p => p && p !== '-').map(normPath), B = list(b).filter(p => p && p !== '-').map(normPath);
|
|
301
|
+
return A.some(x => B.some(y => x === '.' || y === '.' || x === y || x.startsWith(y + '/') || y.startsWith(x + '/')));
|
|
302
|
+
}
|
|
303
|
+
function estimates(run) {
|
|
304
|
+
const tasks = list(run && run.tasks), samples = [];
|
|
305
|
+
for (const t of tasks) for (const e of list(t.executions)) if (e.role === 'task' && num(e.duration_s) !== null) samples.push(e.duration_s / weight(t));
|
|
306
|
+
samples.sort((a, b) => a - b);
|
|
307
|
+
const unit = samples.length ? samples[Math.floor(samples.length / 2)] : 600;
|
|
308
|
+
return t => Math.max(30, unit * weight(t));
|
|
309
|
+
}
|
|
310
|
+
function projectSchedule(run, { now = null, maxParallel = null } = {}) {
|
|
311
|
+
const tasks = list(run && run.tasks), est = estimates(run), cap = Math.max(1, num(maxParallel) || num(run && run.settings && run.settings.max_parallel) || 3);
|
|
312
|
+
const byId = new Map(tasks.map(t => [t.id, t]));
|
|
313
|
+
const lastEnd = Math.max(...tasks.flatMap(t => list(t.executions).map(e => execEnd(e, now)).filter(v => v !== null)), num(run && run.timeline && run.timeline.started) || 0);
|
|
314
|
+
let clock = num(now) !== null ? now : lastEnd || 0;
|
|
315
|
+
const merged = new Map(), active = new Map(), out = {};
|
|
316
|
+
for (const t of tasks) {
|
|
317
|
+
if (t.status === 'MERGED') merged.set(t.id, num(t.merged_at) || clock);
|
|
318
|
+
else if (t.status === 'PASS') merged.set(t.id, clock); // the scheduler merges a saved PASS before anything starts
|
|
319
|
+
else if (t.status === 'RUNNING') {
|
|
320
|
+
const tl = taskTimes(t, null, clock);
|
|
321
|
+
const end = Math.max(clock + 30, (tl.first !== null ? tl.first : clock) + est(t));
|
|
322
|
+
active.set(t.id, end); out[t.id] = { start: tl.first !== null ? tl.first : clock, end, running: true };
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const pending = tasks.filter(t => !merged.has(t.id) && !active.has(t.id) && ['PENDING', 'BLOCKED', 'INFRA'].includes(t.status || 'PENDING')).map(t => t.id);
|
|
326
|
+
let guard = tasks.length * 4 + 8;
|
|
327
|
+
while ((pending.length || active.size) && guard-- > 0) {
|
|
328
|
+
for (const id of pending.slice()) {
|
|
329
|
+
if (active.size >= cap) break;
|
|
330
|
+
const t = byId.get(id);
|
|
331
|
+
if (list(t.deps).some(d => byId.has(d) && !merged.has(d))) continue;
|
|
332
|
+
if ([...active.keys()].some(o => overlap(t.files, byId.get(o).files))) continue;
|
|
333
|
+
const end = clock + est(t);
|
|
334
|
+
active.set(id, end); out[id] = { start: clock, end, ghost: true };
|
|
335
|
+
pending.splice(pending.indexOf(id), 1);
|
|
336
|
+
}
|
|
337
|
+
if (!active.size) break;
|
|
338
|
+
const [id, end] = [...active].sort((a, b) => a[1] - b[1])[0];
|
|
339
|
+
clock = Math.max(clock, end); active.delete(id); merged.set(id, end);
|
|
340
|
+
}
|
|
341
|
+
for (const id of pending) out[id] = { start: null, end: null, blocked: true };
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* ---------- execution: attempts placed on wall-clock time, in lanes ---------- */
|
|
346
|
+
const LANE_D = 118, BLOCK_D = 90, EXEC_PAD = 40, RULER = 60;
|
|
347
|
+
function execItems(run, now) {
|
|
348
|
+
const items = [];
|
|
349
|
+
for (const t of list(run && run.tasks)) {
|
|
350
|
+
const execs = list(t.executions);
|
|
351
|
+
let tops = execs.filter(e => e.role === 'task');
|
|
352
|
+
if (!tops.length) tops = execs.filter(e => e.role === 'solo');
|
|
353
|
+
if (!tops.length && execs.length) { // no task-level record: span its dwarf and qa calls
|
|
354
|
+
const s = Math.min(...execs.map(e => num(e.started)).filter(v => v !== null));
|
|
355
|
+
if (Number.isFinite(s)) tops = [{ id: 'span:' + t.id, role: 'task', started: s, ended: Math.max(...execs.map(e => execEnd(e, now) || s)), state: execs.some(e => e.state === 'running') ? 'running' : 'done', attempt: t.attempt, synthetic: true }];
|
|
356
|
+
}
|
|
357
|
+
const rows = list(t.attempts);
|
|
358
|
+
for (const e of tops) {
|
|
359
|
+
const start = num(e.started); if (start === null) continue;
|
|
360
|
+
const kids = execs.filter(k => k !== e && (k.parent === e.id || e.synthetic || (tops.length === 1 && !k.parent && (k.role === 'dwarf' || k.role === 'qa'))));
|
|
361
|
+
const row = rows.filter(r => r.attempt === e.attempt).pop();
|
|
362
|
+
const status = e.state === 'running' ? 'RUNNING' : row ? row.status : e.state === 'orphaned' ? 'INTERRUPTED' : t.status === 'MERGED' && e === tops[tops.length - 1] ? 'MERGED' : execStatus(e);
|
|
363
|
+
const end = execEnd(e, now);
|
|
364
|
+
const running = kids.find(k => k.state === 'running');
|
|
365
|
+
items.push({ id: e.id, task: t.id, attempt: e.attempt, start, end: end !== null ? Math.max(end, start) : start, running: e.state === 'running',
|
|
366
|
+
status: e === tops[tops.length - 1] && t.status === 'MERGED' ? 'MERGED' : status, role: running ? running.role : null, phase: running ? running.phase : null,
|
|
367
|
+
segs: kids.filter(k => k.role === 'dwarf' || k.role === 'qa').map(k => ({ id: k.id, role: k.role, start: num(k.started), end: execEnd(k, now), status: execStatus(k) })).filter(s => s.start !== null) });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return items;
|
|
371
|
+
}
|
|
372
|
+
// scale: {t0, t1} of a previous model, kept while every block still fits inside it, so a live run does not
|
|
373
|
+
// change the time axis (and so every block) on each poll
|
|
374
|
+
function execModel(run, { now = null, ghosts = true, maxParallel = null, scale = null } = {}) {
|
|
375
|
+
const items = execItems(run, now);
|
|
376
|
+
const cap = Math.max(1, num(maxParallel) || num(run && run.settings && run.settings.max_parallel) || 3);
|
|
377
|
+
const live = items.some(i => i.running) || list(run && run.tasks).some(t => t.status === 'RUNNING');
|
|
378
|
+
const proj = ghosts && run && run.kind !== 'solo' ? projectSchedule(run, { now: now !== null ? now : undefined, maxParallel: cap }) : {};
|
|
379
|
+
for (const [id, p] of Object.entries(proj)) if (p.ghost && p.start !== null) items.push({ id: 'ghost:' + id, task: id, ghost: true, start: p.start, end: p.end, status: 'PENDING', segs: [] });
|
|
380
|
+
const extra = [];
|
|
381
|
+
for (const v of list(run && run.timeline && run.timeline.verifications)) if (num(v.started) !== null) extra.push({ id: v.id, kind: 'verification', start: v.started, end: execEnd(v, now) || v.started, status: v.state === 'running' ? 'RUNNING' : v.exit_code === 0 ? 'PASS' : v.exit_code == null ? 'UNKNOWN' : 'FAIL', segs: [] });
|
|
382
|
+
for (const r of list(run && run.final_reviews)) {
|
|
383
|
+
const s = num(r.started) ?? num(r.at); if (s === null) continue;
|
|
384
|
+
const e = num(r.ended) ?? (r.state === 'running' && num(now) !== null ? now : s + 1);
|
|
385
|
+
extra.push({ id: 'review:' + r.name, kind: 'review', review: r.name, start: s, end: Math.max(s, e), status: r.state === 'running' ? 'RUNNING' : r.verdict || 'UNKNOWN', findings: r.findings || null, segs: [] });
|
|
386
|
+
}
|
|
387
|
+
const all = items.concat(extra);
|
|
388
|
+
if (!all.length) return { items: [], extra: [], lanes: 0, cap, overLimit: false, t0: null, t1: null, k: 1, px: 900, py: 300, ruler: [], live, nowX: null };
|
|
389
|
+
let t0 = Math.min(...all.map(i => i.start));
|
|
390
|
+
let t1 = Math.max(...all.map(i => i.end), num(now) !== null && live ? now : -Infinity);
|
|
391
|
+
if (scale && num(scale.t0) === t0 && num(scale.t1) !== null && t1 <= scale.t1) t1 = scale.t1;
|
|
392
|
+
else if (live) {
|
|
393
|
+
// headroom, rounded to a whole step, so growing blocks do not rescale every poll
|
|
394
|
+
const raw = Math.max(60, (t1 - t0) * 1.25 + 120), unit = [60, 300, 600, 1800, 3600, 7200, 21600].find(u => raw / u <= 12) || 21600;
|
|
395
|
+
t1 = t0 + Math.ceil(raw / unit) * unit;
|
|
396
|
+
}
|
|
397
|
+
const span = Math.max(60, t1 - t0);
|
|
398
|
+
const W = clamp(260 * Math.sqrt(Math.max(1, items.length)), 900, 2600), k = W / span;
|
|
399
|
+
// greedy interval partitioning: the real attempts first (in start order), then the projected ones
|
|
400
|
+
const lanesEnd = [];
|
|
401
|
+
const place = it => {
|
|
402
|
+
let lane = lanesEnd.findIndex(e => e <= it.start + 1);
|
|
403
|
+
if (lane < 0) { lane = lanesEnd.length; lanesEnd.push(it.end); } else lanesEnd[lane] = it.end;
|
|
404
|
+
it.lane = lane;
|
|
405
|
+
};
|
|
406
|
+
items.filter(i => !i.ghost).sort((a, b) => a.start - b.start || a.end - b.end).forEach(place);
|
|
407
|
+
items.filter(i => i.ghost).sort((a, b) => a.start - b.start).forEach(place);
|
|
408
|
+
const lanes = Math.max(1, lanesEnd.length);
|
|
409
|
+
const x = t => EXEC_PAD + (t - t0) * k;
|
|
410
|
+
for (const it of items) { it.x = x(it.start); it.w = Math.max(22, (it.end - it.start) * k); it.y = RULER + it.lane * LANE_D + (LANE_D - BLOCK_D) / 2; it.d = BLOCK_D; }
|
|
411
|
+
const fy = RULER + lanes * LANE_D + 24;
|
|
412
|
+
const finishLanes = [['verification', 'VERIFY'], ['review', 'FINAL REVIEW']].filter(([k]) => extra.some(e => e.kind === k));
|
|
413
|
+
finishLanes.forEach(([kind], i) => extra.filter(e => e.kind === kind).forEach(e => { e.lane = 'f' + i; e.x = x(e.start); e.w = Math.max(22, (e.end - e.start) * k); e.y = fy + i * LANE_D + (LANE_D - BLOCK_D) / 2; e.d = BLOCK_D; }));
|
|
414
|
+
const steps = [60, 300, 600, 900, 1800, 3600, 7200, 14400, 43200, 86400];
|
|
415
|
+
const step = steps.find(s => span / s <= 10) || 86400;
|
|
416
|
+
const ruler = [];
|
|
417
|
+
for (let t = Math.ceil(t0 / step) * step; t <= t0 + span; t += step) ruler.push({ t, x: x(t), label: fmtDur(t - t0) });
|
|
418
|
+
return { items, extra, lanes, finishLanes: finishLanes.map(([k, name], i) => ({ kind: k, name, y: fy + i * LANE_D })), cap, overLimit: lanes > cap, t0, t1: t0 + span, k,
|
|
419
|
+
px: EXEC_PAD * 2 + W, py: fy + finishLanes.length * LANE_D + 10, laneD: LANE_D, ruler, live, nowX: num(now) !== null && live ? x(now) : null, x };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* ---------- the critical path and how busy the slots were ---------- */
|
|
423
|
+
function criticalPath(run, { now = null } = {}) {
|
|
424
|
+
const tasks = list(run && run.tasks), byId = new Map(tasks.map(t => [t.id, t]));
|
|
425
|
+
const T = new Map(tasks.map(t => [t.id, taskTimes(t, null, now)]));
|
|
426
|
+
const end = t => num(t.merged_at) ?? T.get(t.id).last;
|
|
427
|
+
const timed = tasks.filter(t => end(t) !== null);
|
|
428
|
+
if (timed.length) {
|
|
429
|
+
let cur = timed.reduce((a, b) => end(b) > end(a) ? b : a);
|
|
430
|
+
const ids = [cur.id], waits = [], seen = new Set(ids);
|
|
431
|
+
for (;;) {
|
|
432
|
+
const deps = list(cur.deps).map(d => byId.get(d)).filter(d => d && !seen.has(d.id) && end(d) !== null);
|
|
433
|
+
if (!deps.length) break;
|
|
434
|
+
const d = deps.reduce((a, b) => end(b) > end(a) ? b : a);
|
|
435
|
+
const first = T.get(cur.id).first;
|
|
436
|
+
if (first !== null && first - end(d) > 30) waits.push({ from: d.id, to: cur.id, seconds: first - end(d) });
|
|
437
|
+
ids.unshift(d.id); seen.add(d.id); cur = d;
|
|
438
|
+
}
|
|
439
|
+
return { mode: 'exec', ids, waits };
|
|
440
|
+
}
|
|
441
|
+
// nothing ran yet: the heaviest chain by difficulty
|
|
442
|
+
const best = new Map(), visiting = new Set();
|
|
443
|
+
const walk = t => {
|
|
444
|
+
if (best.has(t.id)) return best.get(t.id);
|
|
445
|
+
if (visiting.has(t.id)) return { w: 0, path: [] };
|
|
446
|
+
visiting.add(t.id);
|
|
447
|
+
let top = { w: 0, path: [] };
|
|
448
|
+
for (const d of list(t.deps)) { const dt = byId.get(d); if (dt && dt !== t) { const r = walk(dt); if (r.w > top.w) top = r; } }
|
|
449
|
+
visiting.delete(t.id);
|
|
450
|
+
const r = { w: top.w + weight(t), path: top.path.concat(t.id) }; best.set(t.id, r); return r;
|
|
451
|
+
};
|
|
452
|
+
let top = { w: 0, path: [] };
|
|
453
|
+
for (const t of tasks) { const r = walk(t); if (r.w > top.w) top = r; }
|
|
454
|
+
return { mode: 'plan', ids: top.path, waits: [] };
|
|
455
|
+
}
|
|
456
|
+
function concurrency(run, { now = null } = {}) {
|
|
457
|
+
const ev = [];
|
|
458
|
+
for (const t of list(run && run.tasks)) for (const e of list(t.executions)) {
|
|
459
|
+
if (!['task', 'dwarf', 'qa', 'solo'].includes(e.role)) continue;
|
|
460
|
+
const s = num(e.started), x = execEnd(e, now); if (s === null || x === null) continue;
|
|
461
|
+
const key = e.role === 'solo' ? 'task' : e.role;
|
|
462
|
+
ev.push([s, key, 1], [Math.max(s, x), key, -1]);
|
|
463
|
+
}
|
|
464
|
+
ev.sort((a, b) => a[0] - b[0] || a[2] - b[2]);
|
|
465
|
+
const cur = { task: 0, dwarf: 0, qa: 0 }, series = [];
|
|
466
|
+
let peak = 0;
|
|
467
|
+
for (const [t, k, d] of ev) {
|
|
468
|
+
cur[k] += d;
|
|
469
|
+
if (series.length && series[series.length - 1].t === t) Object.assign(series[series.length - 1], cur);
|
|
470
|
+
else series.push({ t, ...cur });
|
|
471
|
+
peak = Math.max(peak, cur.task, cur.dwarf + cur.qa);
|
|
472
|
+
}
|
|
473
|
+
return { series, peak, cap: num(run && run.settings && run.settings.max_parallel) || 3 };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* ---------- replay: what every node looked like at time t ---------- */
|
|
477
|
+
function stateAt(run, t) {
|
|
478
|
+
const tasks = {};
|
|
479
|
+
for (const task of list(run && run.tasks)) {
|
|
480
|
+
const execs = list(task.executions);
|
|
481
|
+
let status = 'PENDING', role = null;
|
|
482
|
+
if (num(task.merged_at) !== null && task.merged_at <= t) status = 'MERGED';
|
|
483
|
+
else {
|
|
484
|
+
const cover = execs.filter(e => num(e.started) !== null && e.started <= t && (execEnd(e, t) === null || execEnd(e, t) > t || (e.state === 'running' && num(e.ended) === null)));
|
|
485
|
+
const top = cover.find(e => e.role === 'task' || e.role === 'solo') || cover[0];
|
|
486
|
+
if (top) { status = 'RUNNING'; const k = cover.find(e => e.role === 'dwarf' || e.role === 'qa'); role = k ? k.role : null; }
|
|
487
|
+
else {
|
|
488
|
+
const rows = list(task.attempts).filter(r => num(r.at) !== null && r.at <= t);
|
|
489
|
+
if (rows.length) status = rows[rows.length - 1].status;
|
|
490
|
+
else if (execs.some(e => num(e.started) !== null && e.started <= t)) status = 'RUNNING';
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
tasks[task.id] = { status, role };
|
|
494
|
+
}
|
|
495
|
+
const fr = list(run && run.final_reviews).filter(r => (num(r.started) ?? num(r.at) ?? Infinity) <= t);
|
|
496
|
+
const last = fr.sort((a, b) => (a.at || 0) - (b.at || 0)).pop();
|
|
497
|
+
const final = !last ? 'NONE' : num(last.ended) !== null && last.ended <= t ? (last.verdict || 'UNKNOWN') : 'RUNNING';
|
|
498
|
+
const vs = list(run && run.timeline && run.timeline.verifications).filter(v => num(v.started) !== null && v.started <= t);
|
|
499
|
+
const lv = vs.pop();
|
|
500
|
+
const verification = !lv ? 'PENDING' : num(lv.ended) !== null && lv.ended <= t ? (lv.exit_code === 0 ? 'PASS' : 'FAIL') : 'RUNNING';
|
|
501
|
+
return { t, tasks, final, verification, integration: integrationStatus(Object.values(tasks).map(s => s.status)) };
|
|
502
|
+
}
|
|
503
|
+
function timeRange(run, now) {
|
|
504
|
+
const s = [], e = [];
|
|
505
|
+
for (const t of list(run && run.tasks)) for (const x of list(t.executions)) { if (num(x.started) !== null) s.push(x.started); const end = execEnd(x, now); if (end !== null) e.push(end); }
|
|
506
|
+
for (const r of list(run && run.final_reviews)) { const v = num(r.started) ?? num(r.at); if (v !== null) s.push(v); if (num(r.ended) !== null) e.push(r.ended); }
|
|
507
|
+
for (const v of list(run && run.timeline && run.timeline.verifications)) { if (num(v.started) !== null) s.push(v.started); if (num(v.ended) !== null) e.push(v.ended); }
|
|
508
|
+
if (!s.length) return null;
|
|
509
|
+
return { start: Math.min(...s), end: Math.max(...e, ...s) };
|
|
510
|
+
}
|
|
511
|
+
// A task's route through the plan: the tasks it waits on, however indirectly (ancestors), and the tasks that wait
|
|
512
|
+
// on it (descendants). Unknown ids and dependency cycles end the walk instead of looping.
|
|
513
|
+
function lineage(run, id) {
|
|
514
|
+
const up = new Map(), down = new Map();
|
|
515
|
+
for (const t of list(run && run.tasks)) {
|
|
516
|
+
if (!t || t.id == null) continue;
|
|
517
|
+
up.set(t.id, list(t.deps));
|
|
518
|
+
for (const d of list(t.deps)) { if (!down.has(d)) down.set(d, []); down.get(d).push(t.id); }
|
|
519
|
+
}
|
|
520
|
+
const walk = next => {
|
|
521
|
+
const out = new Set(), stack = [...(next.get(id) || [])];
|
|
522
|
+
while (stack.length) { const x = stack.pop(); if (x === id || out.has(x) || !up.has(x)) continue; out.add(x); stack.push(...(next.get(x) || [])); }
|
|
523
|
+
return out;
|
|
524
|
+
};
|
|
525
|
+
return { ancestors: walk(up), descendants: walk(down) };
|
|
526
|
+
}
|
|
527
|
+
// Events worth flashing during a replay: attempts ending, escalations, merges, reviews.
|
|
528
|
+
function events(run) {
|
|
529
|
+
const out = [];
|
|
530
|
+
for (const t of list(run && run.tasks)) {
|
|
531
|
+
for (const a of list(t.attempts)) if (num(a.at) !== null) out.push({ t: a.at, kind: 'attempt', task: t.id, status: a.status, attempt: a.attempt });
|
|
532
|
+
for (const e of list(t.escalations)) if (num(e.at) !== null) out.push({ t: e.at, kind: 'escalation', task: t.id, from: e.from, to: e.to });
|
|
533
|
+
if (num(t.merged_at) !== null) out.push({ t: t.merged_at, kind: 'merge', task: t.id });
|
|
534
|
+
}
|
|
535
|
+
for (const r of list(run && run.final_reviews)) if (num(r.ended) !== null) out.push({ t: r.ended, kind: 'review', name: r.name, verdict: r.verdict });
|
|
536
|
+
return out.sort((a, b) => a.t - b.t);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Changes when what is drawn changes shape (not when a status or a time moves).
|
|
540
|
+
function structureKey(run) {
|
|
541
|
+
return JSON.stringify([run && run.key, run && run.kind,
|
|
542
|
+
list(run && run.tasks).map(t => [t.id, list(t.deps), t.wave, t.difficulty]),
|
|
543
|
+
!!(run && run.final_review), list(run && run.final_reviews).length]);
|
|
544
|
+
}
|
|
545
|
+
function innerKey(task) {
|
|
546
|
+
return JSON.stringify([list(task && task.executions).filter(e => e.role === 'dwarf' || e.role === 'qa').map(e => e.id), list(task && task.shadow).map(s => [s.attempt, s.k]), list(task && task.escalations).length]);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/* ---------- quick jump: an index of runs and tasks, ranked against what was typed ---------- */
|
|
550
|
+
function searchIndex(runs) {
|
|
551
|
+
const out = [];
|
|
552
|
+
for (const r of list(runs)) {
|
|
553
|
+
const goal = String(r.goal || 'Untitled run').split('\n')[0], n = num(r.task_count) || list(r.task_statuses).length;
|
|
554
|
+
out.push({ id: 'run:' + r.key, kind: 'run', key: r.key, label: goal, sub: `${basename(r.repo)} · ${r.run_id || r.key} · ${r.kind === 'solo' ? 'solo' : n + ' task' + (n === 1 ? '' : 's')}`,
|
|
555
|
+
ids: [r.run_id, r.key].filter(Boolean), words: [basename(r.repo), ...list(r.models)], t: num(r.updated) || 0, state: runState(r), href: '#/runs/' + encodeURIComponent(r.key) });
|
|
556
|
+
if (r.kind === 'solo') continue;
|
|
557
|
+
for (const t of list(r.task_statuses)) {
|
|
558
|
+
if (!t || !t.id) continue;
|
|
559
|
+
out.push({ id: `task:${r.key}:${t.id}`, kind: 'task', key: r.key, task: t.id, label: t.title ? `${t.id} · ${t.title}` : t.id, sub: `${goal} · ${String(t.status || 'PENDING').toLowerCase()}`,
|
|
560
|
+
ids: [t.id], words: [r.run_id, basename(r.repo)].filter(Boolean), t: num(r.updated) || 0, status: t.status, subSearch: false, // not found through its run's goal
|
|
561
|
+
href: `#/runs/${encodeURIComponent(r.key)}/tasks/${encodeURIComponent(t.id)}` });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
// how well one query token matches a text: exact, prefix, word start, substring, then a loose subsequence
|
|
567
|
+
function tokenScore(text, q, loose = true) {
|
|
568
|
+
const s = String(text || '').toLowerCase(); if (!s || !q) return null;
|
|
569
|
+
if (s === q) return { score: 100, at: 0, len: q.length };
|
|
570
|
+
const i = s.indexOf(q);
|
|
571
|
+
if (i === 0) return { score: 80, at: 0, len: q.length };
|
|
572
|
+
if (i > 0) return { score: /[^a-z0-9]/.test(s[i - 1]) ? 65 : 50, at: i, len: q.length };
|
|
573
|
+
if (!loose || q.length < 2) return null;
|
|
574
|
+
let j = 0, run = 0, best = 0, starts = 0, first = -1, last = -1;
|
|
575
|
+
for (let k = 0; k < s.length && j < q.length; k++) {
|
|
576
|
+
if (s[k] !== q[j]) { run = 0; continue; }
|
|
577
|
+
if (first < 0) first = k;
|
|
578
|
+
run++; best = Math.max(best, run); if (k === 0 || /[^a-z0-9]/.test(s[k - 1])) starts++;
|
|
579
|
+
j++; last = k;
|
|
580
|
+
}
|
|
581
|
+
if (j < q.length || last - first + 1 > q.length * 3) return null; // all of it, close together
|
|
582
|
+
return { score: Math.max(5, 18 + best * 3 + starts * 4 - (s.length - q.length) / 8), at: -1, len: 0 };
|
|
583
|
+
}
|
|
584
|
+
// entries from searchIndex() and any others ({id, kind, label, sub, ids, words, t}); recent: {id: epoch} picked before
|
|
585
|
+
function searchRank(entries, query, { now = null, recent = {}, limit = 40 } = {}) {
|
|
586
|
+
const q = String(query || '').trim().toLowerCase(), tokens = q.split(/\s+/).filter(Boolean);
|
|
587
|
+
const t0 = num(now) ?? Math.max(0, ...list(entries).map(e => num(e.t) || 0));
|
|
588
|
+
const fresh = e => num(e.t) ? 12 * Math.exp(-Math.max(0, t0 - e.t) / (7 * 86400)) : 0;
|
|
589
|
+
const scored = [];
|
|
590
|
+
for (const e of list(entries)) {
|
|
591
|
+
let total = 0, mark = null;
|
|
592
|
+
if (!tokens.length) total = (recent[e.id] ? 200 + (recent[e.id] / 1e10) : 0) + (e.state === 'live' ? 30 : 0) + fresh(e) + (e.kind === 'run' ? 5 : e.kind === 'command' ? 1 : -50);
|
|
593
|
+
else {
|
|
594
|
+
let ok = true;
|
|
595
|
+
for (const tok of tokens) {
|
|
596
|
+
let best = null;
|
|
597
|
+
const fields = [[e.label, 1, true], ...list(e.ids).map(v => [v, 1.15, true]), ...(e.subSearch === false ? [] : [[e.sub, .55, false]]), ...list(e.words).map(v => [v, .6, false])];
|
|
598
|
+
for (const [text, w, loose] of fields) {
|
|
599
|
+
const m = tokenScore(text, tok, loose); if (!m) continue;
|
|
600
|
+
const sc = m.score * w;
|
|
601
|
+
if (!best || sc > best.sc) best = { sc };
|
|
602
|
+
}
|
|
603
|
+
if (!best) { ok = false; break; }
|
|
604
|
+
total += best.sc;
|
|
605
|
+
const at = String(e.label || '').toLowerCase().indexOf(tok);
|
|
606
|
+
if (at >= 0 && !mark) mark = [at, at + tok.length];
|
|
607
|
+
}
|
|
608
|
+
if (!ok) continue;
|
|
609
|
+
total += fresh(e) + (recent[e.id] ? 15 : 0) + (e.state === 'live' ? 4 : 0);
|
|
610
|
+
}
|
|
611
|
+
if (total > 0 || !tokens.length) scored.push({ ...e, score: total, mark });
|
|
612
|
+
}
|
|
613
|
+
scored.sort((a, b) => b.score - a.score || (b.t || 0) - (a.t || 0) || String(a.label).localeCompare(String(b.label)));
|
|
614
|
+
return scored.slice(0, limit);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ---------- notifications: what changed between two polls of the run list ---------- */
|
|
618
|
+
const FAILING = new Set(['FAIL', 'ERROR', 'CONFLICT', 'TIMEOUT', 'INVALIDATED', 'INTERRUPTED', 'BLOCKED']);
|
|
619
|
+
// prev, next: RunSummary lists (prev null on the first poll, which is only a baseline). A run that has not
|
|
620
|
+
// changed for two hours (stale: its process died) never fires.
|
|
621
|
+
function statusEvents(prev, next, { now = null, staleAfter = 7200 } = {}) {
|
|
622
|
+
if (!Array.isArray(prev)) return [];
|
|
623
|
+
const before = new Map(prev.map(r => [r.key, r])), out = [], t = num(now);
|
|
624
|
+
for (const r of list(next)) {
|
|
625
|
+
const p = before.get(r.key); if (!p) continue;
|
|
626
|
+
if (t !== null && num(r.updated) !== null && t - r.updated > staleAfter) continue;
|
|
627
|
+
const goal = String(r.goal || 'Untitled run').split('\n')[0], base = { key: r.key, run_id: r.run_id || '', goal, repo: basename(r.repo), t: num(r.updated) };
|
|
628
|
+
const was = runState(p), is = runState(r);
|
|
629
|
+
if (was === 'live' && (is === 'done' || is === 'failed')) out.push({ ...base, kind: is === 'done' ? 'run-done' : 'run-failed', id: `run|${r.key}|${is}`, href: '#/runs/' + encodeURIComponent(r.key) });
|
|
630
|
+
const old = new Map(list(p.task_statuses).map(x => [x.id, x.status]));
|
|
631
|
+
for (const x of list(r.task_statuses)) {
|
|
632
|
+
if (!FAILING.has(x.status) || old.get(x.id) === x.status || !old.has(x.id)) continue;
|
|
633
|
+
out.push({ ...base, kind: 'task-failed', task: x.id, status: x.status, title: x.title || '', id: `task|${r.key}|${x.id}|${x.status}`, href: `#/runs/${encodeURIComponent(r.key)}/tasks/${encodeURIComponent(x.id)}` });
|
|
634
|
+
}
|
|
635
|
+
if ((r.final_review === 'PASS' || r.final_review === 'FAIL') && p.final_review !== r.final_review) out.push({ ...base, kind: 'review', verdict: r.final_review, id: `review|${r.key}|${r.final_review}`, href: `#/runs/${encodeURIComponent(r.key)}/final-review` });
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* ---------- Fractal: a task's child tasks as a tidy tree (depth across, siblings down) ---------- */
|
|
641
|
+
// nodes: TaskFractal.nodes (tree order, parent ids); returns {pos: {id: {col, row, depth}}, edges: [[parent, child]], cols, rows}
|
|
642
|
+
function fractalTree(nodes) {
|
|
643
|
+
const ns = list(nodes).filter(n => n && n.id), ids = new Set(ns.map(n => n.id)), kids = new Map();
|
|
644
|
+
const roots = [];
|
|
645
|
+
for (const n of ns) {
|
|
646
|
+
const p = n.parent && ids.has(n.parent) && n.parent !== n.id ? n.parent : null;
|
|
647
|
+
if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(n.id); } else roots.push(n.id);
|
|
648
|
+
}
|
|
649
|
+
const pos = {}, edges = [], seen = new Set();
|
|
650
|
+
let next = 0, cols = 0;
|
|
651
|
+
const place = (id, depth) => {
|
|
652
|
+
if (seen.has(id)) return null; seen.add(id); // a cycle in bad data stops here
|
|
653
|
+
const ch = (kids.get(id) || []).map(k => place(k, depth + 1)).filter(r => r !== null);
|
|
654
|
+
for (const k of kids.get(id) || []) if (pos[k]) edges.push([id, k]);
|
|
655
|
+
const row = ch.length ? (ch[0] + ch[ch.length - 1]) / 2 : next++;
|
|
656
|
+
pos[id] = { col: depth, row, depth }; cols = Math.max(cols, depth + 1);
|
|
657
|
+
return row;
|
|
658
|
+
};
|
|
659
|
+
for (const r of roots) place(r, 0);
|
|
660
|
+
for (const n of ns) if (!seen.has(n.id)) place(n.id, 0); // what a cycle left out
|
|
661
|
+
return { pos, edges, cols, rows: next };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return { liveNow, runState, execStatus, matchRun, filterOptions, atlasModel, columns, planModel, finishState, taskParts,
|
|
665
|
+
taskTimes, metricValue, overlap, projectSchedule, execModel, criticalPath, concurrency, stateAt, timeRange, events, lineage, structureKey, innerKey,
|
|
666
|
+
fmtDur, alias, basename, weight, searchIndex, searchRank, statusEvents, fractalTree };
|
|
667
|
+
});
|