@giamme/forge 0.11.0 → 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 (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +46 -12
  5. package/docs/commands.md +48 -9
  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-issues.py +1 -1
  18. package/scripts/forge-lib-plans.sh +9 -3
  19. package/scripts/forge-lib-review.sh +22 -3
  20. package/scripts/forge-lib-task.sh +51 -4
  21. package/scripts/forge-lib-verify.sh +137 -6
  22. package/scripts/forge-parallel-options.sh +59 -0
  23. package/scripts/forge-parallel.sh +212 -28
  24. package/scripts/forge-ripwire.py +1 -1
  25. package/scripts/forge-route.py +157 -5
  26. package/scripts/forge-schedule.py +687 -50
  27. package/scripts/forge-ui.py +8 -4
  28. package/scripts/forge-verify.py +878 -0
  29. package/scripts/forge_dataset/forecast.py +29 -4
  30. package/scripts/forge_dataset/harvest.py +10 -4
  31. package/scripts/forge_verify_reports.py +225 -0
  32. package/ui/README.md +19 -4
  33. package/ui/docs/api.md +139 -25
  34. package/ui/forge_ui/control.py +26 -7
  35. package/ui/forge_ui/export.py +20 -7
  36. package/ui/forge_ui/reader.py +307 -11
  37. package/ui/forge_ui/server.py +16 -0
  38. package/ui/forge_ui/static/app.js +2 -1
  39. package/ui/forge_ui/static/controls.js +217 -33
  40. package/ui/forge_ui/static/findingsview.js +37 -12
  41. package/ui/forge_ui/static/gantt.js +14 -9
  42. package/ui/forge_ui/static/index.html +1 -0
  43. package/ui/forge_ui/static/panel.js +2 -1
  44. package/ui/forge_ui/static/runmodel.js +92 -11
  45. package/ui/forge_ui/static/scene3d.js +9 -5
  46. package/ui/forge_ui/static/settings.js +48 -12
  47. package/ui/forge_ui/static/style.css +60 -0
  48. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -287,7 +287,9 @@
287
287
  return {
288
288
  integration: { status: integrationStatus(tasks.map(t => t.status)),
289
289
  merged, total: tasks.length, base: ig && ig.base || null, accepted: ig && ig.accepted || null },
290
- verification: { status: v && v.status ? String(v.status).toUpperCase() : 'PENDING', command: v && v.command || null, runs: list(v && v.runs).length },
290
+ verification: { status: v && v.state === 'running' ? 'RUNNING' : v && v.status ? String(v.status).toUpperCase() : 'PENDING',
291
+ command: v && v.command || null, runs: list(v && v.runs).length, mode: v && v.mode === 'lanes' && list(v.lanes).length ? 'lanes' : 'single',
292
+ lanes: list(v && v.lanes).map(l => ({ name: l.name, state: l.state })), reruns: list(v && v.runs).filter(r => r.kind !== 'initial').length },
291
293
  final: { status: !frObj ? (run.settings && run.settings.final_qa ? 'PENDING' : 'NONE') : finalStatus(frObj),
292
294
  requested: !!(fr || run.settings && run.settings.final_qa), spec: frObj && frObj.spec || run.settings && run.settings.final_qa || null, findings: frObj && frObj.findings || null,
293
295
  count: list(run.final_reviews).length,
@@ -475,20 +477,68 @@
475
477
  }
476
478
  return items;
477
479
  }
480
+ // A verification run's status: exit 0 passed, any other exit failed, no exit is RUNNING while it runs and UNKNOWN after.
481
+ const runStatus = (exit, running) => exit === 0 ? 'PASS' : num(exit) !== null ? 'FAIL' : running ? 'RUNNING' : 'UNKNOWN';
482
+ const LANE_STATUS = { flaky: 'FLAKY', known: 'KNOWN' };
483
+ // The finish rows' verification items, each with its `row`: in lanes mode a row per lane of the newest verification,
484
+ // one item per run of it (the newest verification's earlier executions stay boxes on a VERIFY row); in single mode
485
+ // the newest verification split into its runs, back to back from its start (`approx`: the execution also covers
486
+ // discovery and fingerprinting, and a run's duration its fingerprint), else one box per execution.
487
+ function verifyItems(run, now) {
488
+ const v = run && run.verification, execs = list(run && run.timeline && run.timeline.verifications).filter(e => num(e.started) !== null);
489
+ const newest = execs.reduce((a, e) => !a || e.started >= a.started ? e : a, null);
490
+ const lanes = v && v.mode === 'lanes' ? list(v.lanes).filter(l => l && l.name) : [];
491
+ const out = [];
492
+ for (const e of execs) {
493
+ if (e === newest && lanes.length) continue;
494
+ const runs = e === newest ? list(v && v.runs) : [];
495
+ const running = e.state === 'running';
496
+ if (!runs.length) {
497
+ out.push({ id: e.id, kind: 'verification', row: 'verification', start: e.started, end: execEnd(e, now) || e.started,
498
+ status: running ? 'RUNNING' : e.exit_code === 0 ? 'PASS' : e.exit_code == null ? 'UNKNOWN' : 'FAIL', segs: [] });
499
+ continue;
500
+ }
501
+ let t = e.started;
502
+ runs.forEach((r, i) => {
503
+ const d = Math.max(0, num(r.duration_s) || 0), rerun = r.kind !== 'initial';
504
+ out.push({ id: `vrun:${e.id}:${r.n ?? i + 1}`, kind: 'verification', row: 'verification', exec: e.id, approx: true, rerun,
505
+ start: t, end: t + d, status: runStatus(num(r.exit), false), segs: [],
506
+ title: `verify · run ${r.n ?? i + 1}${rerun ? ' (' + String(r.kind).replace('_', ' ') + ')' : ''} · approximate placement` });
507
+ t += d;
508
+ });
509
+ // runs.tsv gains a row when a run ends: a verification still going is in a run that is not listed yet
510
+ if (running && num(now) !== null && now > t) out.push({ id: `vrun:${e.id}:now`, kind: 'verification', row: 'verification', exec: e.id, approx: true,
511
+ start: t, end: now, status: 'RUNNING', segs: [], title: 'verify · the current run · approximate placement' });
512
+ }
513
+ for (const l of lanes) {
514
+ const runs = list(l.runs);
515
+ runs.forEach((r, i) => {
516
+ const s = num(r.started); if (s === null) return;
517
+ const last = i === runs.length - 1, running = last && l.state === 'running' && num(r.ended) === null;
518
+ const end = num(r.ended) ?? (running && num(now) !== null ? Math.max(s, now) : s);
519
+ const status = last && LANE_STATUS[l.state] ? LANE_STATUS[l.state] : runStatus(num(r.exit), running);
520
+ const k = num(r.k) ?? i + 1, rerun = r.kind === 'rerun';
521
+ const notes = [l.group ? 'group ' + l.group : null, l.first ? 'runs first' : null].filter(Boolean);
522
+ out.push({ id: `vlane:${l.name}:${k}`, kind: 'verification', row: 'vlane:' + l.name, vlane: l.name, k, rerun, exec: newest ? newest.id : null,
523
+ start: s, end, running, status, segs: [], title: `verify · ${l.name} · ${rerun ? 'rerun ' + (k - 1) : 'run ' + k}${notes.length ? ' · ' + notes.join(' · ') : ''}` });
524
+ });
525
+ }
526
+ return { items: out, lanes: lanes.map(l => l.name) };
527
+ }
478
528
  // scale: {t0, t1} of a previous model, kept while every block still fits inside it, so a live run does not
479
529
  // change the time axis (and so every block) on each poll
480
530
  function execModel(run, { now = null, ghosts = true, maxParallel = null, scale = null } = {}) {
481
531
  const items = execItems(run, now);
482
532
  const cap = Math.max(1, num(maxParallel) || num(run && run.settings && run.settings.max_parallel) || 3);
483
- const live = items.some(i => i.running) || list(run && run.tasks).some(t => t.status === 'RUNNING');
533
+ const live = items.some(i => i.running) || list(run && run.tasks).some(t => t.status === 'RUNNING')
534
+ || !!(run && run.verification && run.verification.state === 'running');
484
535
  const proj = ghosts && run && run.kind !== 'solo' ? projectSchedule(run, { now: now !== null ? now : undefined, maxParallel: cap }) : {};
485
536
  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: [] });
486
- const extra = [];
487
- 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: [] });
537
+ const verify = verifyItems(run, now), extra = verify.items;
488
538
  for (const r of list(run && run.final_reviews)) {
489
539
  const s = num(r.started) ?? num(r.at); if (s === null) continue;
490
540
  const e = num(r.ended) ?? (r.state === 'running' && num(now) !== null ? now : s + 1);
491
- 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: [] });
541
+ extra.push({ id: 'review:' + r.name, kind: 'review', row: 'review', review: r.name, start: s, end: Math.max(s, e), status: r.state === 'running' ? 'RUNNING' : r.verdict || 'UNKNOWN', findings: r.findings || null, segs: [] });
492
542
  }
493
543
  const all = items.concat(extra);
494
544
  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 };
@@ -515,13 +565,15 @@
515
565
  const x = t => EXEC_PAD + (t - t0) * k;
516
566
  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; }
517
567
  const fy = RULER + lanes * LANE_D + 24;
518
- const finishLanes = [['verification', 'VERIFY'], ['review', 'FINAL REVIEW']].filter(([k]) => extra.some(e => e.kind === k));
519
- 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; }));
568
+ // finish rows: VERIFY (single-command verifications), a row per verification lane, FINAL REVIEW
569
+ const finishLanes = [['verification', 'verification', 'VERIFY', 'verify'], ...verify.lanes.map(n => ['vlane:' + n, 'verification', 'VERIFY · ' + n, 'verify · ' + n]),
570
+ ['review', 'review', 'FINAL REVIEW', 'final review']].filter(([row]) => extra.some(e => e.row === row));
571
+ finishLanes.forEach(([row], i) => extra.filter(e => e.row === row).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; }));
520
572
  const steps = [60, 300, 600, 900, 1800, 3600, 7200, 14400, 43200, 86400];
521
573
  const step = steps.find(s => span / s <= 10) || 86400;
522
574
  const ruler = [];
523
575
  for (let t = Math.ceil(t0 / step) * step; t <= t0 + span; t += step) ruler.push({ t, x: x(t), label: fmtDur(t - t0) });
524
- 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,
576
+ return { items, extra, lanes, finishLanes: finishLanes.map(([row, kind, name, label], i) => ({ kind, row, name, label, y: fy + i * LANE_D })), cap, overLimit: lanes > cap, t0, t1: t0 + span, k,
525
577
  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 };
526
578
  }
527
579
 
@@ -602,8 +654,36 @@
602
654
  const final = finalAt(run, last, t);
603
655
  const vs = list(run && run.timeline && run.timeline.verifications).filter(v => num(v.started) !== null && v.started <= t);
604
656
  const lv = vs.pop();
605
- const verification = !lv ? 'PENDING' : num(lv.ended) !== null && lv.ended <= t ? (lv.exit_code === 0 ? 'PASS' : 'FAIL') : 'RUNNING';
606
- return { t, tasks, final, verification, integration: integrationStatus(Object.values(tasks).map(s => s.status)) };
657
+ const verification = !lv ? 'PENDING' : num(lv.ended) !== null && lv.ended <= t ? runStatus(num(lv.exit_code), false) : 'RUNNING';
658
+ return { t, tasks, final, verification, lanes: lanesAt(run, t), integration: integrationStatus(Object.values(tasks).map(s => s.status)) };
659
+ }
660
+ // The finish node's verification LEDs: one per lane coloured by its state (at the replay time when `at` is
661
+ // stateAt's), else one per run of the command by its exit; a run with no exit is UNKNOWN, never FAIL.
662
+ const LED_STATE = { pass: 'PASS', fail: 'FAIL', running: 'RUNNING', flaky: 'FLAKY', known: 'KNOWN', interrupted: 'INTERRUPTED' };
663
+ function verifyLeds(run, at) {
664
+ const v = run && run.verification;
665
+ if (v && v.mode === 'lanes' && list(v.lanes).length) {
666
+ if (at && at.lanes) return at.lanes.map(l => l.status === 'PENDING' ? null : l.status);
667
+ return list(v.lanes).filter(l => l && l.name).map(l => LED_STATE[l.state] || null);
668
+ }
669
+ return list(v && v.runs).map(r => runStatus(num(r.exit), false));
670
+ }
671
+ // Each verification lane at time t (lanes mode, else null): PENDING before its first run, RUNNING during a run,
672
+ // between runs and after the last the status of the run that ended last, a flaky or known lane's own at its end.
673
+ function lanesAt(run, t) {
674
+ const v = run && run.verification;
675
+ if (!v || v.mode !== 'lanes' || !list(v.lanes).length) return null;
676
+ return list(v.lanes).filter(l => l && l.name).map(l => {
677
+ let status = 'PENDING';
678
+ const runs = list(l.runs);
679
+ runs.forEach((r, i) => {
680
+ const s = num(r.started), e = num(r.ended), last = i === runs.length - 1;
681
+ if (s === null || s > t) return;
682
+ if (e === null || e > t) { status = 'RUNNING'; return; }
683
+ status = last && LANE_STATUS[l.state] ? LANE_STATUS[l.state] : runStatus(num(r.exit), false);
684
+ });
685
+ return { name: l.name, status };
686
+ });
607
687
  }
608
688
  function timeRange(run, now) {
609
689
  const s = [], e = [];
@@ -618,6 +698,7 @@
618
698
  for (const m of list(run && run.integration && run.integration.merged)) if (num(m && m.at) !== null) e.push(m.at);
619
699
  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); }
620
700
  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); }
701
+ for (const l of list(run && run.verification && run.verification.lanes)) for (const r of list(l && l.runs)) if (num(r && r.ended) !== null) e.push(r.ended);
621
702
  if (!s.length) return null;
622
703
  return { start: Math.min(...s), end: Math.max(...e, ...s) };
623
704
  }
@@ -777,6 +858,6 @@
777
858
 
778
859
  return { liveNow, runState, execStatus, matchRun, filterOptions, atlasModel, columns, planModel, finishState,
779
860
  finalStatus, runReviewStatus, reviewSinceLine, reviewAt, taskStatus, taskVerdict, attemptStatus, taskParts,
780
- taskTimes, metricValue, overlap, projectSchedule, execModel, criticalPath, concurrency, stateAt, timeRange, events, lineage, structureKey, innerKey,
861
+ taskTimes, metricValue, overlap, projectSchedule, execModel, verifyLeds, criticalPath, concurrency, stateAt, timeRange, events, lineage, structureKey, innerKey,
781
862
  fmtDur, alias, basename, weight, searchIndex, searchRank, statusEvents, fractalTree };
782
863
  });
@@ -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
  }
@@ -85,13 +85,25 @@
85
85
  return { editor: { base: fresh, draft: e.draft }, dirty: true, moved: norm(fresh) !== norm(e.base) };
86
86
  }
87
87
  const edited = (editor, draft) => ({ base: editor.base, draft: text(draft) });
88
+ // The editor after typing in a textarea drawn from the file text `from`. When a read moved `base` on since the textarea
89
+ // was drawn (a held body is not redrawn), the typing sits on the old text, so the save would overwrite the new file.
90
+ function typed(editor, draft, from) {
91
+ const e = edited(editor, draft), dirty = isDirty(e);
92
+ return { editor: e, dirty, moved: dirty && norm(from) !== norm(e.base) };
93
+ }
88
94
  const shown = editor => editor.draft !== null ? editor.draft : editor.base;
95
+ // What a failed GET /api/config does: with a good read of this run on the page, it stays and a note names the
96
+ // failure; with none yet, the message replaces the page. Status 0 is no answer at all.
97
+ function readFailure(res, hasData) {
98
+ const message = (res && res.status !== 0 && (res.detail || res.error)) || 'could not reach forge-ui';
99
+ return { keep: !!hasData, message };
100
+ }
89
101
 
90
102
  /* ---------- DOM ---------- */
91
103
  function render(host, ctx) {
92
104
  const UI = root.ForgeUI, c = ctx || {};
93
105
  const el = (tag, t, cls) => UI.el(tag, t, cls);
94
- const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false };
106
+ const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false, asking: 0, asks: 0, pickerLate: false };
95
107
  const key_ = (label, cls, onclick) => { const b = el('button', label, cls); b.type = 'button'; if (onclick) b.addEventListener('click', onclick); return b; };
96
108
 
97
109
  const head = el('header', undefined, 'set-head');
@@ -101,10 +113,17 @@
101
113
  const body = el('div', undefined, 'set-body');
102
114
  const note = el('p', 'This page is read-only: this server has no write token, or has controls off.', 'muted set-readonly');
103
115
  note.hidden = true;
104
- host.replaceChildren(head, picker, note, body);
116
+ // A failed read over a good one: the page stays, and this says the shown values may be old. Outside the body, so a
117
+ // held body (an open confirm) still gets it.
118
+ const stale = el('p', undefined, 'warn set-problem set-stale'); stale.setAttribute('role', 'status');
119
+ stale.hidden = true;
120
+ host.replaceChildren(head, picker, note, stale, body);
121
+ const staleNote = msg => { stale.hidden = !msg; stale.textContent = msg ? `The last read of the configuration failed: ${msg}. This shows the read before it; the next refresh tries again.` : ''; };
105
122
 
106
123
  /* the repository picker */
107
124
  function drawPicker() {
125
+ // The switch confirm lives in the picker: while it is open the rebuild waits, and runs when it is answered.
126
+ if (s.asking) { s.pickerLate = true; return; }
108
127
  const runs = c.runs ? c.runs() : [];
109
128
  const groups = groupRuns(runs);
110
129
  const before = s.run, usable = withRepo(runs);
@@ -138,7 +157,14 @@
138
157
  }
139
158
  sel.addEventListener('change', async () => {
140
159
  const unsaved = FILES.filter(n => s.editors[n] && isDirty(s.editors[n]));
141
- if (unsaved.length && !(await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true }))) { sel.value = s.gone ? '' : s.run; return; }
160
+ if (unsaved.length) {
161
+ const ask = s.asking = ++s.asks;
162
+ const yes = await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true });
163
+ if (s.asking !== ask) return; // a later change asked again, and that confirm decides
164
+ const late = s.pickerLate;
165
+ s.asking = 0; s.pickerLate = false;
166
+ if (!yes) { sel.value = s.gone ? '' : s.run; if (late) drawPicker(); return; }
167
+ }
142
168
  pick(sel.value);
143
169
  });
144
170
  const lab = el('label', undefined, 'set-field'); lab.htmlFor = 'set-run';
@@ -151,11 +177,13 @@
151
177
  }
152
178
  // A new run: nothing of the old one stays, and confirms of the old body (now detached) no longer hold a redraw.
153
179
  function resetRun() {
180
+ staleNote(null);
154
181
  s.data = null; s.dataText = null; s.error = null; s.editors = {}; s.moved = {}; s.loading = s.run != null;
155
182
  s.gen++; s.hold = 0; s.redraw = false;
156
183
  }
157
184
  function pick(key) {
158
185
  s.run = key; resetRun();
186
+ s.asking = 0; s.pickerLate = false; // a switch confirm still open goes with the old picker
159
187
  s.runsSig = null; drawPicker(); drawBody();
160
188
  if (c.onPick) c.onPick(key);
161
189
  load();
@@ -171,12 +199,12 @@
171
199
  if (seq !== s.seq || run !== s.run) return; // a later pick or load answered
172
200
  s.loading = false;
173
201
  if (!res.ok) {
174
- const msg = res.status === 0 ? null : res.detail || res.error;
175
- if (msg === null && s.data) return; // a poll that did not reach the server keeps the last good page
202
+ const failed = readFailure(res, s.data);
203
+ if (failed.keep) { staleNote(failed.message); return; } // the last good page, its editors and their unsaved text stay
176
204
  if (s.hold > 0) return; // a confirm or a write is open: its strip must not be torn down
177
- s.error = msg || 'could not reach forge-ui'; s.data = null; s.dataText = null; drawBody(); return;
205
+ s.error = failed.message; s.data = null; s.dataText = null; drawBody(); return;
178
206
  }
179
- s.error = null;
207
+ s.error = null; staleNote(null);
180
208
  const files = object(object(res.data.repo).files);
181
209
  for (const name of FILES) {
182
210
  const next = dirtyState(s.editors[name], object(files[name]).content);
@@ -305,7 +333,9 @@
305
333
  const top = el('div', undefined, 'set-file-head'); top.append(h, status);
306
334
  art.append(top, el('p', blurb, 'muted set-file-blurb'));
307
335
  if (file.note) art.append(el('p', text(file.note), 'warn set-file-note'));
308
- if (isDirty(ed) && s.moved[name]) art.append(el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note'));
336
+ const movedNote = el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note');
337
+ movedNote.hidden = !(isDirty(ed) && s.moved[name]); // sync() keeps it current as the text is typed
338
+ art.append(movedNote);
309
339
  if (!s.writes) {
310
340
  status.replaceChildren(el('i', undefined, 'lamp ' + (file.exists ? 'st-pass' : '')), el('span', file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
311
341
  art.append(unreadable ? el('p', file.exists ? 'The content is not shown.' : 'Not set.', 'muted') : el('pre', text(file.content), 'set-pre'));
@@ -316,6 +346,7 @@
316
346
  ta.dataset.editor = name; ta.rows = Math.max(3, Math.min(14, shown(ed).split('\n').length + 1));
317
347
  ta.spellcheck = false; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocomplete', 'off');
318
348
  ta.value = shown(ed);
349
+ let from = ed.base; // the file text this textarea was drawn from
319
350
  if (unreadable && file.exists) { ta.disabled = true; ta.placeholder = 'The content is not shown, so it cannot be edited here.'; }
320
351
  const problem = el('p', undefined, 'warn set-problem'); problem.hidden = true; problem.id = id + '-problem';
321
352
  const result = el('div', undefined, 'set-result');
@@ -328,10 +359,15 @@
328
359
  if (bad) { ta.setAttribute('aria-invalid', 'true'); ta.setAttribute('aria-describedby', problem.id); }
329
360
  else { ta.removeAttribute('aria-invalid'); ta.removeAttribute('aria-describedby'); }
330
361
  save.disabled = !dirtyNow || !!bad;
362
+ movedNote.hidden = !(dirtyNow && s.moved[name]);
331
363
  status.replaceChildren(el('i', undefined, 'lamp ' + (dirtyNow ? 'set-amber' : file.exists ? 'st-pass' : '')),
332
364
  el('span', dirtyNow ? 'unsaved edits' : file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
333
365
  };
334
- ta.addEventListener('input', () => { s.editors[name] = edited(s.editors[name], ta.value); sync(); });
366
+ ta.addEventListener('input', () => {
367
+ const next = typed(s.editors[name], ta.value, from);
368
+ s.editors[name] = next.editor; s.moved[name] = next.moved || (next.dirty && !!s.moved[name]);
369
+ sync();
370
+ });
335
371
  remove.disabled = !file.exists;
336
372
  save.addEventListener('click', async () => {
337
373
  const cur = s.editors[name], bad = valueProblem(name, cur.draft);
@@ -342,7 +378,7 @@
342
378
  if (!yes) { ta.readOnly = false; remove.disabled = !file.exists; release(); sync(); save.focus({ preventScroll: true }); return; }
343
379
  const res = await UI.post('/api/config', configBody(s.run, repoKey(name), cur.draft));
344
380
  UI.outcome(result, res, `Saved ${target}.`);
345
- if (res.ok && gen === s.gen) s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n');
381
+ if (res.ok && gen === s.gen) { s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n'); s.moved[name] = false; from = s.editors[name].base; }
346
382
  ta.readOnly = false; remove.disabled = !file.exists; release(); sync();
347
383
  if (res.ok && gen === s.gen) await reload();
348
384
  });
@@ -353,7 +389,7 @@
353
389
  if (!yes) { ta.readOnly = false; release(); sync(); remove.disabled = !file.exists; remove.focus({ preventScroll: true }); return; }
354
390
  const res = await UI.post('/api/config', configBody(s.run, repoKey(name), null, true));
355
391
  UI.outcome(result, res, `Removed ${target}.`);
356
- if (res.ok && gen === s.gen) s.editors[name] = editorOf('');
392
+ if (res.ok && gen === s.gen) { s.editors[name] = editorOf(''); s.moved[name] = false; from = ''; }
357
393
  ta.readOnly = false; release();
358
394
  if (res.ok && gen === s.gen) await reload(); else if (!res.ok) { remove.disabled = !file.exists; sync(); }
359
395
  });
@@ -395,5 +431,5 @@
395
431
  }
396
432
 
397
433
  return { render, configBody, repoKey, targetPath, subsetOk, valueProblem, pickDefault, groupRuns, optionLabel,
398
- editorOf, isDirty, dirtyState, edited, shown, FILES, VALUE_MAX };
434
+ editorOf, isDirty, dirtyState, edited, typed, shown, readFailure, FILES, VALUE_MAX };
399
435
  });
@@ -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; }