@giamme/forge 0.11.1 → 0.12.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -289,7 +289,11 @@ export async function createStage(host, cb = {}) {
289
289
  function finishInfo(id) {
290
290
  const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
291
291
  if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
292
- if (id === 'f:verification') return { title: 'VERIFICATION', sub: F.verification.command || 'no command recorded', status: F.verification.status, line: F.verification.runs ? `${F.verification.runs} run${F.verification.runs > 1 ? 's' : ''}` : 'not run yet' };
292
+ if (id === 'f:verification') {
293
+ const V = F.verification, n = V.lanes.length, plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
294
+ const line = V.mode === 'lanes' ? `${plural(n, 'lane')} · ${plural(V.reruns, 'rerun')}` : V.runs ? plural(V.runs, 'run') : 'not run yet';
295
+ return { title: 'VERIFICATION', sub: V.command || (V.mode === 'lanes' ? V.lanes.map(l => l.name).join(' · ') : 'no command recorded'), status: V.status, line };
296
+ }
293
297
  const status = finishStatus(id, { status: F.final.status });
294
298
  const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
295
299
  const f = F.final.findings;
@@ -313,7 +317,7 @@ export async function createStage(host, cb = {}) {
313
317
  }, 'cover', { x: x + 12, y: y + 8, z: h });
314
318
  if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
315
319
  else if (m.id === 'f:integration') leds(el, Math.min(40, list(st.run.tasks).length), x + 17, y + d - 13, h + .16, { step: 8, s: 2.4, perRow: 22 });
316
- else leds(el, Math.max(1, Math.min(12, list(st.run.verification && st.run.verification.runs).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
320
+ else leds(el, Math.max(1, Math.min(12, RM.verifyLeds(st.run).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
317
321
  finishLeds(el);
318
322
  display(el, e => finishInfo(e.id).line || '', { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
319
323
  halo(el);
@@ -326,7 +330,7 @@ export async function createStage(host, cb = {}) {
326
330
  function finishLeds(el) {
327
331
  if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
328
332
  else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
329
- else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
333
+ else setLeds(el, RM.verifyLeds(st.run, st.replay !== null ? st.replayState : null));
330
334
  }
331
335
  function buildPlan(g) {
332
336
  const P = RM.planModel(st.run); st.plan = P;
@@ -512,7 +516,7 @@ export async function createStage(host, cb = {}) {
512
516
  const lw = Math.max(10, it.w - 10);
513
517
  el.roof = label(el, lw, it.d - 14, 4, (cx, s, C, e) => {
514
518
  const item = e.data.item, sel = e.cur.sel > .5;
515
- const title = item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
519
+ const title = item.vlane ? 'verify · ' + item.vlane : item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
516
520
  const f = Math.min(11, Math.max(4, (lw - 4) / Math.max(4, title.length * E.CW)));
517
521
  E.font(cx, f, s, 600); cx.fillStyle = sel ? C.accHi : item.ghost ? C.ink : C.inkHi; cx.fillText(E.ellipsis(title, Math.floor((lw - 2) / (f * E.CW))), 1 * s, (f + 1) * s);
518
522
  // segments: when the dwarf and the review ran inside this attempt
@@ -1135,7 +1139,7 @@ export async function createStage(host, cb = {}) {
1135
1139
  if (el.kind === 'finish') return { kind: el.id === 'f:final' ? 'final' : el.id === 'f:verification' ? 'verification' : 'integration' };
1136
1140
  const it = el.data.item; if (!it) return null;
1137
1141
  if (it.kind === 'review') return { kind: 'final', name: it.review };
1138
- if (it.kind === 'verification') return { kind: 'execution', task: null, exec: it.id };
1142
+ if (it.kind === 'verification') return it.vlane || it.approx ? { kind: 'verification' } : { kind: 'execution', task: null, exec: it.id };
1139
1143
  if (it.ghost || String(it.id).startsWith('span:')) return { kind: 'task', id: it.task };
1140
1144
  return { kind: 'execution', task: it.task, id: it.task, exec: it.id };
1141
1145
  }
@@ -99,6 +99,9 @@
99
99
  .st-fail, .st-error, .st-conflict, .st-infra, .st-timeout, .st-blocked { --st: var(--lamp-red); --st-lit: var(--lit-red); }
100
100
  [class^="st-"], [class*=" st-"] { --st-fill: var(--st); --st-ring: var(--lamp-edge); }
101
101
  .st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked, .st-outdated { --st-fill: transparent; --st-ring: var(--st); }
102
+ /* verification lanes: flaky passed on a rerun (amber, hollow), known passed with only baseline failures (green, hollow) */
103
+ .st-flaky { --st: var(--lamp-amber); --st-lit: var(--lit-amber); --st-fill: transparent; --st-ring: var(--st); }
104
+ .st-known { --st: var(--lamp-green); --st-lit: var(--lit-green); --st-fill: transparent; --st-ring: var(--st); }
102
105
  /* review severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin) on a ramp of its own, never a status lamp */
103
106
  .sev-P0 { --sev: var(--sev-p0); --sev-shape: polygon(50% 0, 100% 100%, 0 100%); }
104
107
  .sev-P1 { --sev: var(--sev-p1); --sev-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
@@ -563,6 +566,51 @@ summary:hover { color: var(--ink-2); }
563
566
  ul.plain { list-style: none; padding: 0; margin: 4px 0; display: grid; gap: 4px; font-size: var(--t-sm); }
564
567
  ul.plain li.current { font-weight: 650; }
565
568
  pre.cmd { max-height: none; }
569
+ /* the verification inspector (verifyview.js): live state, failing tests, lanes and their logs */
570
+ .vfy-num { font-variant-numeric: tabular-nums; }
571
+ .vfy-live { margin: var(--s-3) 0 0; padding: var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 0 0 0 1px var(--rule); }
572
+ .vfy-now { margin: 0; font-size: var(--t-sm); }
573
+ .vfy-step { font-weight: 600; }
574
+ .vfy-last { margin: var(--s-2) 0 0; font-size: var(--t-xs); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
575
+ .vfy-idle { margin: var(--s-2) 0 0; padding: 2px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); font-size: var(--t-xs); width: fit-content; }
576
+ .vfy-group { margin: var(--s-3) 0 0; }
577
+ .vfy-group-head { display: flex; gap: var(--s-2); align-items: baseline; margin: 0 0 var(--s-1); font-size: var(--t-sm); }
578
+ .vfy-tests { gap: var(--s-2); }
579
+ .vfy-test { padding: var(--s-2) var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 3px 0 0 var(--lamp-red); }
580
+ .vfy-test.vfy-known { box-shadow: inset 3px 0 0 var(--lamp-off); }
581
+ .vfy-test-head { display: flex; flex-wrap: wrap; gap: var(--s-2); align-items: baseline; }
582
+ .vfy-test-name { overflow-wrap: anywhere; font-size: var(--t-xs); }
583
+ .vfy-badge { padding: 0 6px; border-radius: var(--r-small); font-size: var(--t-xs); font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
584
+ .vfy-badge-new { background: var(--lamp-red); color: var(--plate-ink); }
585
+ .vfy-badge-known { box-shadow: inset 0 0 0 1px var(--rule-2); color: var(--muted); }
586
+ .vfy-message { margin: var(--s-1) 0 0; font-size: var(--t-sm); overflow-wrap: anywhere; }
587
+ .vfy-excerpt summary { cursor: pointer; font-size: var(--t-xs); color: var(--muted); }
588
+ /* a link-like button: none of the key's plate type, face or press */
589
+ .vfy-link { display: inline; min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; color: var(--ink); text-decoration: underline; text-underline-offset: 2px;
590
+ font-family: var(--font-ui); font-weight: 400; font-size: var(--t-xs); letter-spacing: normal; text-transform: none; line-height: inherit; white-space: normal; text-align: left; }
591
+ .vfy-link.mono { font-family: var(--mono); }
592
+ .vfy-link:hover:not(:disabled) { background: none; color: var(--ink-2); }
593
+ .vfy-section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); margin: 0 0 var(--s-2); }
594
+ .vfy-section-head h3 { margin: 0; }
595
+ .vfy-nowrap { white-space: nowrap; }
596
+ .vfy-table-wrap { overflow-x: auto; }
597
+ .vfy-table { width: 100%; border-collapse: collapse; font-size: var(--t-sm); }
598
+ .vfy-table th { text-align: left; font-weight: 600; color: var(--muted); font-size: var(--t-xs); padding: 0 var(--s-2) var(--s-1) 0; }
599
+ .vfy-table td { padding: var(--s-1) var(--s-2) var(--s-1) 0; border-top: 1px solid var(--rule); vertical-align: top; }
600
+ .vfy-table tr.vfy-followed td { background: var(--module-2); }
601
+ .vfy-lane .vfy-link { font-size: var(--t-sm); }
602
+ .vfy-label { margin-left: var(--s-1); font-size: var(--t-xs); }
603
+ .vfy-lane-now { display: grid; gap: 2px; margin-top: 2px; max-width: 28ch; }
604
+ .vfy-lane-now .vfy-last { margin: 0; }
605
+ .vfy-log { display: grid; padding: var(--s-2) 0; font-family: var(--mono); font-size: var(--t-xs); line-height: 1.5; white-space: pre; overflow: auto; }
606
+ .vfy-line { display: flex; gap: var(--s-3); padding: 0 var(--s-3); }
607
+ .vfy-line.vfy-hl { background: var(--warn-wash); color: var(--warn-ink); }
608
+ .vfy-ln { flex: none; min-width: 5ch; text-align: right; color: var(--glass-dim); font-variant-numeric: tabular-nums; user-select: none; }
609
+ .vfy-line.vfy-hl .vfy-ln { color: inherit; }
610
+ .vfy-lt { white-space: pre-wrap; overflow-wrap: anywhere; }
611
+ .vfy-note { font-size: var(--t-xs); margin: var(--s-1) 0 0; }
612
+ .vfy-summary, .vfy-endstep, .vfy-mutated { margin: var(--s-3) 0 0; font-size: var(--t-sm); }
613
+ .vfy-lanes + .fields, .vfy-tail + .fields { margin-top: var(--s-4); }
566
614
  .phase-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin: 6px 0; background: var(--module-2); }
567
615
  .phase { min-width: 2px; }
568
616
  .phase-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: var(--t-xs); color: var(--muted); }
@@ -624,6 +672,17 @@ p.ctl-why::before { content: ''; width: 7px; height: 7px; border-radius: 50%; ba
624
672
  .ctl-pin-now { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
625
673
  p.ctl-stop { display: flex; align-items: center; gap: 7px; margin: 0; padding: 4px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); color: var(--warn-ink); font-weight: 600; }
626
674
  .ctl-log { margin-top: var(--s-1); }
675
+ /* the run's keys (drain, resume, stop), its drained or stopping state, pauses and a queued retry's answer */
676
+ .ctl-keys { display: flex; flex-wrap: wrap; gap: var(--s-2); }
677
+ .ctl-state { font-variant-numeric: tabular-nums; }
678
+ .ctl-stopping, .ctl-drained { background: var(--warn-wash); color: var(--warn-ink); border-color: transparent; }
679
+ .ctl-pauses { display: grid; gap: var(--s-1); }
680
+ .ctl-pause-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
681
+ .ctl-pause { display: flex; align-items: baseline; gap: 7px; }
682
+ .ctl-pause-text { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
683
+ .ctl-exhausted .ctl-pause-text { color: var(--warn-ink); font-weight: 600; }
684
+ p.ctl-request { display: flex; align-items: center; gap: 7px; margin: 0; font-size: var(--t-sm); }
685
+ p.ctl-request-refused { color: var(--warn-ink); font-weight: 600; }
627
686
  .ctl-changes { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
628
687
  .ctl-changes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; }
629
688
  .ctl-what { color: var(--ink); font-weight: 600; }
@@ -702,6 +761,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
702
761
  .gt-item.gt-sel .gt-box { stroke: var(--route); stroke-width: 2.5; }
703
762
  .gt-item.gt-crit .gt-box { stroke-width: 2.4; }
704
763
  .gt-ghost .gt-box { fill-opacity: .04; stroke-dasharray: 4 3; }
764
+ .gt-item.gt-approx .gt-box { stroke-dasharray: 2 2; } /* a verification run placed from durations, not from its own times */
705
765
  .gt-future { opacity: .3; }
706
766
  .gt-seg { fill: var(--st, var(--lamp-off)); }
707
767
  .gt-seg.gt-dwarf { fill-opacity: .55; }
@@ -0,0 +1,324 @@
1
+ /* forge-ui verification view: what the verification runs now, its lanes, and which tests failed
2
+ (window.ForgeVerify, and a CommonJS module under node:test). panel.js's verification inspector delegates here.
3
+ The pure helpers build a view model from RunDetail.verification (ui/docs/api.md) and are tested in
4
+ ui/tests/js/verifyview.test.js; render(host, ctx) is thin DOM over them. Test names, messages, excerpts and log
5
+ lines are untrusted: they only ever enter the page as text.
6
+ ctx = { run, now (epoch seconds), fetchJSON(path), rerender(), hash(sel), onSelect(sel) } */
7
+ (function (root, factory) {
8
+ 'use strict';
9
+ const api = factory(root);
10
+ if (typeof module === 'object' && module.exports) module.exports = api;
11
+ else root.ForgeVerify = api;
12
+ })(typeof self !== 'undefined' ? self : this, function (root) {
13
+ 'use strict';
14
+
15
+ const IDLE_WARN_S = 300; // silence past this is flagged
16
+ const CONTEXT_LINES = 10; // lines shown above an opened log line
17
+ const list = v => Array.isArray(v) ? v : [];
18
+ const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
19
+ const STEP = /^::forge step name=(\S+) state=(start|end)(?: exit=(-?\d+))?$/;
20
+ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
21
+ const LANE_CHIP = { waiting: 'pending', running: 'running', pass: 'pass', fail: 'fail', flaky: 'flaky', known: 'known',
22
+ skipped: 'none', interrupted: 'interrupted' };
23
+
24
+ /* ---------- pure ---------- */
25
+ function dur(s) {
26
+ s = num(s); if (s === null) return '—';
27
+ s = Math.max(0, Math.round(s));
28
+ if (s < 60) return s + 's';
29
+ if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's';
30
+ return Math.floor(s / 3600) + 'h' + String(Math.floor(s % 3600 / 60)).padStart(2, '0') + 'm';
31
+ }
32
+ const since = (start, now) => num(start) !== null && num(now) !== null ? Math.max(0, now - start) : null;
33
+ // "no output for N min" past five minutes of silence, else null
34
+ function idleWarning(idle) {
35
+ idle = num(idle);
36
+ return idle !== null && idle > IDLE_WARN_S ? `no output for ${Math.floor(idle / 60)} min` : null;
37
+ }
38
+ // The last step a log started and no later line ended (steps nest), or null.
39
+ function openStep(text) {
40
+ const open = [];
41
+ for (const raw of String(text || '').split('\n')) {
42
+ const m = STEP.exec(raw.replace(ANSI, '').trim());
43
+ if (!m) continue;
44
+ if (m[2] === 'start') open.push(m[1]);
45
+ else { const i = open.lastIndexOf(m[1]); if (i >= 0) open.splice(i, 1); }
46
+ }
47
+ return open.length ? open[open.length - 1] : null;
48
+ }
49
+ function countsText(tests) {
50
+ if (!tests || typeof tests !== 'object') return null;
51
+ const n = k => num(tests[k]) || 0;
52
+ const parts = [`${n('pass')} passed`, `${n('fail') + n('error')} failed`];
53
+ if (n('skip')) parts.push(`${n('skip')} skipped`);
54
+ return parts.join(' · ');
55
+ }
56
+ // "rerun 1 of 2" once a lane ran again (of its own budget, when the lane set one)
57
+ function runsText(lane) {
58
+ const reruns = list(lane && lane.runs).filter(r => r && r.kind === 'rerun').length;
59
+ if (!reruns) return '';
60
+ return `rerun ${reruns}${num(lane.retries) !== null ? ' of ' + lane.retries : ''}`;
61
+ }
62
+ function laneRow(lane, now) {
63
+ const running = lane.state === 'running';
64
+ const took = running && num(lane.started) !== null && num(now) !== null ? since(lane.started, now) : num(lane.duration_s);
65
+ return {
66
+ name: String(lane.name || ''), state: lane.state || 'unknown', chip: LANE_CHIP[lane.state] || 'unknown',
67
+ time: took === null ? '' : dur(took), running,
68
+ runs: runsText(lane), counts: countsText(lane.tests), hasTests: !!(lane.tests && typeof lane.tests === 'object'),
69
+ step: running && lane.step && lane.step.name ? lane.step.name : null,
70
+ last_line: running && lane.last_line ? String(lane.last_line) : null,
71
+ idle: running ? idleWarning(lane.idle_s) : null,
72
+ labels: [lane.first ? 'first' : null, lane.group ? 'group ' + lane.group : null].filter(Boolean),
73
+ };
74
+ }
75
+ // failing tests in lane order: [{lane, entries: [{name, badge: new|known, message, excerpt, log_line}], fresh, known}]
76
+ function groupFailing(failing) {
77
+ const groups = new Map();
78
+ for (const f of list(failing)) {
79
+ if (!f || typeof f !== 'object') continue;
80
+ const lane = String(f.lane || '');
81
+ if (!groups.has(lane)) groups.set(lane, { lane, entries: [], fresh: 0, known: 0 });
82
+ const g = groups.get(lane), known = f.known === true;
83
+ g.entries.push({ name: String(f.name || '?'), badge: known ? 'known' : 'new', message: f.message || '', excerpt: f.excerpt || '',
84
+ log_line: num(f.log_line) !== null && f.log_line > 0 ? f.log_line : null, suite: f.suite || null, file: f.file || null });
85
+ if (known) g.known++; else g.fresh++;
86
+ }
87
+ return Array.from(groups.values());
88
+ }
89
+ // The lane whose log the live tail follows: the chosen one if it still exists, else the running lane that printed
90
+ // last, else the first running, failed or listed lane. null in single mode.
91
+ function tailLane(v, chosen) {
92
+ const lanes = list(v && v.lanes);
93
+ if (!lanes.length) return null;
94
+ if (chosen && lanes.some(l => l.name === chosen)) return chosen;
95
+ const running = lanes.filter(l => l.state === 'running');
96
+ const printed = running.filter(l => num(l.last_output_at) !== null).sort((a, b) => b.last_output_at - a.last_output_at);
97
+ const pick = printed[0] || running[0] || lanes.find(l => l.state === 'fail') || lanes[0];
98
+ return pick.name;
99
+ }
100
+ function model(v, now) {
101
+ if (!v) return null;
102
+ const running = v.state === 'running', status = String(v.status || '').toUpperCase();
103
+ const lanes = list(v.lanes).filter(l => l && typeof l === 'object').map(l => laneRow(l, now));
104
+ const failed = !running && (status === 'FAIL' || lanes.some(l => l.state === 'fail'));
105
+ const live = running && v.live ? {
106
+ elapsed: dur(since(v.live.started, now)),
107
+ step: v.live.step && v.live.step.name ? v.live.step.name : null,
108
+ stepElapsed: v.live.step && num(v.live.step.started) !== null ? dur(since(v.live.step.started, now)) : null,
109
+ last_line: v.live.last_line || null,
110
+ idle: idleWarning(v.live.idle_s),
111
+ } : null;
112
+ return {
113
+ running, failed, passed: !running && !failed && status === 'PASS', mode: v.mode === 'lanes' ? 'lanes' : 'single',
114
+ live, lanes, groups: groupFailing(v.failing), mutated: v.mutated === true,
115
+ reportless: failed ? lanes.filter(l => l.state === 'fail' && !l.hasTests).map(l => l.name) : [],
116
+ flaky: lanes.filter(l => l.state === 'flaky').map(l => l.name),
117
+ known: lanes.filter(l => l.state === 'known').map(l => l.name),
118
+ // a single command that ended: the step it was in, from its output's step markers
119
+ endStep: !running && v.mode !== 'lanes' && v.state ? openStep(v.log_tail) : null,
120
+ };
121
+ }
122
+ const runsBase = key => `/api/runs/${encodeURIComponent(key)}/verification`;
123
+ // the log endpoint of the single command (lane null; RETRY for its rerun's log), a lane, or one run k of a lane
124
+ const RETRY = { retry: true };
125
+ function logPath(key, lane, k) {
126
+ if (lane === RETRY) return runsBase(key) + '/retry/log';
127
+ if (!lane) return runsBase(key) + '/log';
128
+ return `${runsBase(key)}/lanes/${encodeURIComponent(lane)}${num(k) !== null ? '/runs/' + k : ''}/log`;
129
+ }
130
+ // the range that shows `line` with a few lines above it
131
+ const linesPath = (key, lane, line) => `${logPath(key, lane)}/lines/${Math.max(1, line - CONTEXT_LINES)}`;
132
+ // lines of a tail as [{n, text}], numbered from tail_first_line when the server knew it
133
+ function tailLines(body) {
134
+ const text = String(body && body.tail || ''), first = num(body && body.tail_first_line);
135
+ const lines = text.endsWith('\n') ? text.slice(0, -1).split('\n') : text.split('\n');
136
+ return (text ? lines : []).map((t, i) => ({ n: first === null ? null : first + i, text: t }));
137
+ }
138
+
139
+ /* ---------- DOM ---------- */
140
+ const views = new Map(); // run key → { lane (followed by the tail), open: {lane, line} | null }
141
+ const cache = new Map(); // path → { data, error, pending, fresh }
142
+ function el(tag, text, className) {
143
+ const node = document.createElement(tag);
144
+ if (text !== undefined && text !== null) node.textContent = String(text);
145
+ if (className) node.className = className;
146
+ return node;
147
+ }
148
+ const chip = (text, cls) => el('span', text || '—', 'chip st-' + cls);
149
+ function section(parent, title, cls) {
150
+ const s = el('section', undefined, 'insp-section' + (cls ? ' ' + cls : ''));
151
+ s.append(el('h3', title)); parent.append(s); return s;
152
+ }
153
+ function button(text, onClick, cls) {
154
+ const b = el('button', text, cls || 'vfy-link'); b.type = 'button';
155
+ b.addEventListener('click', onClick); return b;
156
+ }
157
+ // a fetch that lands once per `fresh` token and re-renders the view when it does
158
+ function load(ctx, path, fresh) {
159
+ const c = cache.get(path);
160
+ if (c && (c.fresh === fresh || c.pending)) return c;
161
+ const entry = { pending: true, fresh, data: c ? c.data : null, error: null };
162
+ cache.set(path, entry);
163
+ ctx.fetchJSON(path).then(data => { entry.data = data; entry.error = null; }, err => { entry.error = err; })
164
+ .finally(() => { entry.pending = false; ctx.rerender(); });
165
+ return entry;
166
+ }
167
+ function logBlock(parent, rows, mark, scrollKey) {
168
+ const pre = el('pre', undefined, 'vfy-log'); pre.dataset.scroll = scrollKey;
169
+ for (const r of rows) {
170
+ const line = el('span', undefined, 'vfy-line' + (mark !== null && r.n === mark ? ' vfy-hl' : ''));
171
+ if (r.n !== null) line.append(el('span', r.n, 'vfy-ln'));
172
+ line.append(el('span', r.text, 'vfy-lt'));
173
+ pre.append(line);
174
+ }
175
+ if (!rows.length) pre.append(el('span', '(empty)', 'vfy-lt'));
176
+ parent.append(pre);
177
+ return pre;
178
+ }
179
+ function tail(parent, ctx, lane, fresh, scrollKey) {
180
+ const entry = load(ctx, logPath(ctx.run.key, lane), fresh);
181
+ if (!entry.data) {
182
+ const why = !entry.error ? 'Reading the log…' : entry.error.snapshot ? 'This export holds no logs.'
183
+ : entry.error.status === 404 ? 'No log to show yet.' : 'Could not read the log.';
184
+ parent.append(el('p', why, 'muted'));
185
+ return;
186
+ }
187
+ if (entry.data.truncated) parent.append(el('p', `the last ${Math.round(String(entry.data.tail || '').length / 1024)} KB`, 'muted vfy-note'));
188
+ logBlock(parent, tailLines(entry.data), null, scrollKey);
189
+ }
190
+ function openedLog(parent, ctx, open) {
191
+ const s = section(parent, `Log of ${open.lane} at line ${open.line}`, 'vfy-opened');
192
+ const head = el('div', undefined, 'vfy-section-head');
193
+ head.append(s.firstChild, button('Close the log', () => { views.get(ctx.run.key).open = null; ctx.rerender(); }, 'vfy-close'));
194
+ s.prepend(head);
195
+ const entry = load(ctx, linesPath(ctx.run.key, open.lane, open.line), 'once');
196
+ if (entry.error) {
197
+ // an export holds no line ranges: the tail, with the line marked when it is in it
198
+ const back = load(ctx, logPath(ctx.run.key, open.lane), 'once');
199
+ if (!back.data) { s.append(el('p', back.error ? 'This log is not available here.' : 'Reading the log…', 'muted')); return; }
200
+ const rows = tailLines(back.data);
201
+ if (!rows.some(r => r.n === open.line)) s.append(el('p', `Line ${open.line} is not in this export; this is the end of the log.`, 'muted vfy-note'));
202
+ reveal(open, logBlock(s, rows, open.line, 'vfy-opened-log'));
203
+ return;
204
+ }
205
+ if (!entry.data) { s.append(el('p', 'Reading the log…', 'muted')); return; }
206
+ const first = num(entry.data.first_line) || 1;
207
+ const pre = logBlock(s, list(entry.data.lines).map((t, i) => ({ n: first + i, text: t })), open.line, 'vfy-opened-log');
208
+ if (entry.data.truncated) s.append(el('p', 'The log goes on past these lines.', 'muted vfy-note'));
209
+ reveal(open, pre);
210
+ }
211
+ // Bring the marked line into view once, after the inspector put its own scroll back.
212
+ function reveal(open, pre) {
213
+ if (open.revealed || !pre.querySelector('.vfy-hl')) return;
214
+ open.revealed = true;
215
+ setTimeout(() => { if (pre.isConnected) pre.querySelector('.vfy-hl')?.scrollIntoView({ block: 'center' }); }, 0);
216
+ }
217
+ function liveBlock(parent, m) {
218
+ const box = el('div', undefined, 'vfy-live');
219
+ const row = el('p', undefined, 'vfy-now');
220
+ row.append(el('span', 'running for ', 'muted'), el('span', m.live.elapsed, 'vfy-num'));
221
+ if (m.live.step) {
222
+ row.append(el('span', ' · step ', 'muted'), el('span', m.live.step, 'mono vfy-step'));
223
+ if (m.live.stepElapsed) row.append(el('span', ' for ', 'muted'), el('span', m.live.stepElapsed, 'vfy-num'));
224
+ }
225
+ box.append(row);
226
+ if (m.live.last_line) box.append(el('p', m.live.last_line, 'mono vfy-last'));
227
+ if (m.live.idle) box.append(el('p', m.live.idle, 'warn vfy-idle'));
228
+ parent.append(box);
229
+ }
230
+ function failingBlock(parent, ctx, m, view) {
231
+ const total = m.groups.reduce((n, g) => n + g.entries.length, 0);
232
+ const s = section(parent, `Failing tests (${total})`, 'vfy-failing');
233
+ for (const g of m.groups) {
234
+ const box = el('div', undefined, 'vfy-group');
235
+ const h = el('h4', undefined, 'vfy-group-head');
236
+ h.append(el('span', g.lane || 'the command', 'mono'), el('span', [g.fresh ? `${g.fresh} new` : '', g.known ? `${g.known} known` : ''].filter(Boolean).join(' · '), 'muted vfy-num'));
237
+ box.append(h);
238
+ const ul = el('ul', undefined, 'plain vfy-tests');
239
+ for (const e of g.entries) {
240
+ const li = el('li', undefined, 'vfy-test vfy-' + e.badge);
241
+ const top = el('div', undefined, 'vfy-test-head');
242
+ top.append(el('span', e.badge, 'vfy-badge vfy-badge-' + e.badge), el('span', e.name, 'mono vfy-test-name'));
243
+ if (e.log_line !== null && g.lane) {
244
+ top.append(button(`open in log (line ${e.log_line})`, () => { view.open = { lane: g.lane, line: e.log_line }; ctx.rerender(); }));
245
+ }
246
+ li.append(top);
247
+ if (e.message) li.append(el('p', e.message, 'vfy-message'));
248
+ if (e.excerpt) {
249
+ const d = el('details', undefined, 'vfy-excerpt'); d.dataset.keep = `vfy-${g.lane}-${e.name}`;
250
+ d.append(el('summary', 'excerpt'), el('pre', e.excerpt));
251
+ li.append(d);
252
+ }
253
+ ul.append(li);
254
+ }
255
+ box.append(ul);
256
+ s.append(box);
257
+ }
258
+ return s;
259
+ }
260
+ function lanesTable(parent, ctx, m, view, followed) {
261
+ const s = section(parent, `Lanes (${m.lanes.length})`, 'vfy-lanes');
262
+ const wrap = el('div', undefined, 'vfy-table-wrap'); wrap.dataset.scroll = 'vfy-lanes';
263
+ const table = el('table', undefined, 'vfy-table');
264
+ const head = el('tr');
265
+ for (const h of ['Lane', 'State', 'Time', 'Runs', 'Tests']) head.append(el('th', h));
266
+ const thead = el('thead'); thead.append(head);
267
+ const body = el('tbody');
268
+ for (const l of m.lanes) {
269
+ const tr = el('tr', undefined, l.name === followed ? 'vfy-followed' : '');
270
+ const name = el('td', undefined, 'vfy-lane');
271
+ name.append(button(l.name, () => { view.lane = l.name; ctx.rerender(); }, 'vfy-link mono'));
272
+ for (const label of l.labels) name.append(el('span', label, 'plate vfy-label'));
273
+ if (l.step || l.last_line) {
274
+ const now = el('div', undefined, 'vfy-lane-now');
275
+ if (l.step) now.append(el('span', 'step ' + l.step, 'mono vfy-step'));
276
+ if (l.last_line) now.append(el('span', l.last_line, 'mono vfy-last'));
277
+ name.append(now);
278
+ }
279
+ if (l.idle) name.append(el('div', l.idle, 'warn vfy-idle'));
280
+ const state = el('td'); state.append(chip(l.state, l.chip));
281
+ tr.append(name, state, el('td', l.time, 'vfy-num vfy-nowrap'), el('td', l.runs, 'vfy-num vfy-nowrap'), el('td', l.counts || '', 'vfy-num'));
282
+ body.append(tr);
283
+ }
284
+ table.append(thead, body); wrap.append(table); s.append(wrap);
285
+ }
286
+ function render(host, ctx) {
287
+ const run = ctx.run, v = run.verification, m = model(v, num(ctx.now) !== null ? ctx.now : Date.now() / 1000);
288
+ if (!m) return;
289
+ if (!views.has(run.key)) views.set(run.key, { lane: null, open: null });
290
+ const view = views.get(run.key);
291
+ // logs are read again when the run's files moved: every poll while the verification prints
292
+ const fresh = `${run.updated}|${v.live ? v.live.last_output_at : ''}`;
293
+ if (m.running && m.live) liveBlock(host, m);
294
+ if (m.failed && m.groups.length) failingBlock(host, ctx, m, view);
295
+ if (view.open && list(v.lanes).some(l => l.name === view.open.lane)) openedLog(host, ctx, view.open);
296
+ if (m.mutated) host.append(el('p', 'A lane changed the checkout, so no lane was rerun after it.', 'warn vfy-mutated'));
297
+ for (const name of m.reportless) {
298
+ const s = section(host, `${name}: no test report; see the log`, 'vfy-reportless');
299
+ tail(s, ctx, name, fresh, 'vfy-tail-' + name);
300
+ }
301
+ if (m.endStep) host.append(el('p', `It ended in step ${m.endStep}.`, 'vfy-endstep'));
302
+ if (m.passed && m.lanes.length) {
303
+ const p = el('p', undefined, 'vfy-summary');
304
+ p.append(el('span', `${m.lanes.length} lane${m.lanes.length === 1 ? '' : 's'} passed`));
305
+ if (m.flaky.length) p.append(el('span', ` · flaky (passed on a rerun): ${m.flaky.join(', ')}`, 'warn'));
306
+ if (m.known.length) p.append(el('span', ` · known failures only: ${m.known.join(', ')}`, 'muted'));
307
+ host.append(p);
308
+ }
309
+ const followed = m.running ? tailLane(v, view.lane) : view.lane;
310
+ if (m.lanes.length) lanesTable(host, ctx, m, view, followed);
311
+ if (m.running) {
312
+ const s = section(host, m.mode === 'lanes' && followed ? `Live output: ${followed}` : 'Live output', 'vfy-tail');
313
+ // a single command's rerun writes its own log: the live output follows the one that moved last
314
+ const single = v.live && v.live.log === 'verification.retry.log' ? RETRY : null;
315
+ tail(s, ctx, m.mode === 'lanes' && followed ? followed : single, fresh, 'vfy-live-tail');
316
+ } else if (m.mode === 'lanes' && view.lane) {
317
+ const s = section(host, `Output: ${view.lane}`, 'vfy-tail');
318
+ tail(s, ctx, view.lane, fresh, 'vfy-lane-tail');
319
+ }
320
+ }
321
+
322
+ return { render, model, laneRow, groupFailing, idleWarning, openStep, countsText, runsText, tailLane, logPath, linesPath, RETRY,
323
+ tailLines, dur, IDLE_WARN_S };
324
+ });