@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
@@ -25,7 +25,13 @@
25
25
  'verify-retries': { label: 'Verify retries', run: 'at the next verification', planOnly: true },
26
26
  'retry-budget': { label: 'Retry budget', run: 'at the next retry decision', task: 'at the next retry decision', live: true },
27
27
  'infra-retries': { label: 'Infra retries', run: 'at the next retry decision', planOnly: true, live: true },
28
+ 'max-parallel': { label: 'Max parallel', run: 'at the next dispatch', planOnly: true, live: true },
29
+ 'infra-backoff': { label: 'Infra backoff', run: 'at the next infrastructure wait', planOnly: true },
30
+ 'escalate-effort': { label: 'Effort step', run: 'at the next retry decision', planOnly: true },
28
31
  };
32
+ const BACKOFF_FORMS = 'exp:<base>[,<cap>], linear:<start>,<step>[,<cap>] or list:<a>,<b>,… (seconds)';
33
+ // A task status after which "Retry now" applies (or the policy stopped it).
34
+ const RETRYABLE = ['FAIL', 'UNKNOWN', 'TIMEOUT', 'ERROR', 'INTERRUPTED', 'BLOCKED'];
29
35
 
30
36
  const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
31
37
  const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
@@ -80,6 +86,16 @@
80
86
  return LEVELS.includes(text) || text === 'none' || (task && text === 'inherit') ? { ok: true, value: text } : bad('P0, P1, P2, P3 or none');
81
87
  }
82
88
  if (task && key === 'retry-budget' && text === 'inherit') return { ok: true, value: 'inherit' };
89
+ if (key === 'max-parallel') {
90
+ return /^[0-9]{1,2}$/.test(text) && Number(text) >= 1 && Number(text) <= 64 ? { ok: true, value: String(Number(text)) } : bad('a whole number, 1 to 64');
91
+ }
92
+ if (key === 'escalate-effort') return text === 'on' || text === 'off' ? { ok: true, value: text } : bad('on or off');
93
+ if (key === 'infra-backoff') {
94
+ // The shape itself is the CLI's to check; here only what can never be one. "default" drops the setting.
95
+ if (text === 'default' || text === 'none') return { ok: true, value: 'none' };
96
+ return text && !/[\s\u0000-\u001f\u007f]/.test(text) && !text.startsWith('-') && text.length <= MAX_FIELD
97
+ ? { ok: true, value: text } : bad('a backoff shape: ' + BACKOFF_FORMS);
98
+ }
83
99
  return key === 'timeout' ? secondsOf(text) : countOf(text);
84
100
  }
85
101
 
@@ -92,11 +108,15 @@
92
108
  }
93
109
  if (action === 'pin') return { action: 'pin', task: a.task, spec: a.spec };
94
110
  if (action === 'unpin') return { action: 'unpin', task: a.task };
111
+ if (action === 'drain' || action === 'resume') return { action };
112
+ if (action === 'stop') return a && a.now ? { action: 'stop', now: true } : { action: 'stop' };
113
+ if (action === 'retry') return a.dwarf ? { action: 'retry', task: a.task, dwarf: a.dwarf } : { action: 'retry', task: a.task };
95
114
  throw new Error('unknown action ' + action);
96
115
  }
97
116
 
98
117
  const shown = (key, value, task) => key === 'timeout' ? `${minutesOf(Number(value))} min`
99
- : key === 'qa-threshold' && value === 'none' && task ? 'none (no threshold for this task)' : value;
118
+ : key === 'qa-threshold' && value === 'none' && task ? 'none (no threshold for this task)'
119
+ : key === 'infra-backoff' && value === 'none' ? 'the default (exp:60)' : value;
100
120
  const inTask = task => task ? ` for task \`${task}\`` : '';
101
121
 
102
122
  // A draft change → {ok, body, confirm} or {ok: false, error}. change: {kind: 'set', key, input, task?} |
@@ -120,6 +140,21 @@
120
140
  return c.task ? { ok: true, body: bodyFor('unpin', { task: c.task }),
121
141
  confirm: `Remove the pin on task \`${c.task}\`? Its next retry goes back to the escalation policy` } : bad('no task');
122
142
  }
143
+ if (c.kind === 'drain') return { ok: true, body: bodyFor('drain'), confirm: 'Drain the run? Running tasks finish; nothing new starts until you resume' };
144
+ if (c.kind === 'resume') return { ok: true, body: bodyFor('resume'), confirm: 'Resume the run? The scheduler starts tasks again' };
145
+ if (c.kind === 'stop') {
146
+ return { ok: true, body: bodyFor('stop', { now: !!c.now }), confirm: c.now
147
+ ? 'Stop the run now? Running tasks are interrupted, as Ctrl-C does; the run ends with no verification'
148
+ : 'Stop the run once its running tasks finish? Nothing new starts, and the run ends with no verification (unless every task has merged by then)' };
149
+ }
150
+ if (c.kind === 'retry') {
151
+ if (!c.task) return bad('no task');
152
+ const named = String(c.alias === undefined || c.alias === null ? '' : c.alias).trim();
153
+ const s = named ? specOf(c) : { ok: true, spec: null };
154
+ if (!s.ok) return s;
155
+ return { ok: true, body: bodyFor('retry', { task: c.task, dwarf: s.spec }),
156
+ confirm: `Retry task \`${c.task}\` now${s.spec ? ` on \`${s.spec}\`` : ''}? It is queued into the live run` };
157
+ }
123
158
  return bad('unknown change');
124
159
  }
125
160
 
@@ -129,6 +164,7 @@
129
164
  if (d.changed && String(rec.previous === undefined || rec.previous === null ? '' : rec.previous) === String(rec.value === undefined || rec.value === null ? '' : rec.value)) {
130
165
  return 'Nothing changed: it was already so.';
131
166
  }
167
+ if (d.queued === true) return 'Queued: the scheduler applies or refuses it at its next turn.';
132
168
  return typeof d.applies === 'string' && d.applies ? `Saved. Applies ${d.applies}.` : 'Saved.';
133
169
  }
134
170
 
@@ -139,8 +175,46 @@
139
175
  qa: LEVELS.includes(run.qa_threshold) ? run.qa_threshold : 'none',
140
176
  timeout: num(run.timeout), timeoutMin: minutesOf(run.timeout),
141
177
  escalateAfter: num(run.escalate_after), verifyRetries: num(run.verify_retries),
142
- retryBudget: num(run.retry_budget), infraBudget: num(run.infra_budget) };
178
+ retryBudget: num(run.retry_budget), infraBudget: num(run.infra_budget),
179
+ maxParallel: num(run.max_parallel), infraBackoff: typeof run.infra_backoff === 'string' ? run.infra_backoff : null,
180
+ escalateEffort: run.escalate_effort === 'on' ? 'on' : 'off', drained: run.drained === true,
181
+ stopping: run.stopping === 'graceful' || run.stopping === 'now' ? run.stopping : null,
182
+ pauses: Array.isArray(run.infra_paused) ? run.infra_paused.filter(x => x && typeof x === 'object') : [] };
183
+ }
184
+ // "3m05s" to a moment, from `now` (epoch seconds); "now" once it has come.
185
+ function countdown(until, now) {
186
+ const s = num(until) === null || num(now) === null ? null : Math.ceil(until - now);
187
+ if (s === null) return null;
188
+ if (s <= 0) return 'now';
189
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor(s % 3600 / 60)).padStart(2, '0')}m`;
190
+ }
191
+ // One line per infrastructure pause (`run.infra_paused`): what is paused, why, its tasks, the wait, and the time to
192
+ // its end, or "budget used up" once its retries are spent.
193
+ function pauseLines(controls, now) {
194
+ return runValues(controls).pauses.map(x => {
195
+ const scope = String(x.scope || '');
196
+ const what = scope === 'all' ? 'every task' : scope.startsWith('model:') ? `model ${x.model || scope.slice(6)}${x.harness ? ' on ' + x.harness : ''}`
197
+ : scope.startsWith('harness:') ? `harness ${x.harness || scope.slice(8)}` : scope || 'a scope';
198
+ const tasks = Array.isArray(x.tasks) && x.tasks.length ? x.tasks.map(String).join(', ') : null;
199
+ const left = x.exhausted === true ? 'budget used up' : countdown(x.until, now);
200
+ return { scope, exhausted: x.exhausted === true, text: [what, x.class ? String(x.class) : null, tasks ? 'tasks ' + tasks : null,
201
+ num(x.cycle) !== null ? `wait ${x.cycle}` : null, left === null ? null : left === 'budget used up' ? left : left === 'now' ? 'resuming' : `resumes in ${left}`]
202
+ .filter(Boolean).join(' · ') };
203
+ });
143
204
  }
205
+ // Where a queued retry stands: {state: 'queued'} until the scheduler's line with the same request id is in the log,
206
+ // then {state: 'applied'} or {state: 'refused', reason}. Only the request id matches a line.
207
+ function requestState(request, log) {
208
+ const line = (Array.isArray(log) ? log : []).find(e => e && e.actor === 'scheduler' && e.request === request);
209
+ if (!request || !line) return { state: 'queued' };
210
+ return line.outcome === 'applied' ? { state: 'applied' } : { state: 'refused', reason: typeof line.reason === 'string' ? line.reason : null };
211
+ }
212
+ const requestText = st => st.state === 'refused' ? `refused${st.reason ? ': ' + st.reason : ''}` : st.state;
213
+ // Whether "Retry now" shows for a task: live only, after it ended without passing or once the policy stopped it.
214
+ const canRetry = (controls, id) => {
215
+ const c = obj(controls), v = taskValues(c, id);
216
+ return c.live === true && v.known && v.status !== 'MERGED' && (RETRYABLE.includes(v.status) || v.stop);
217
+ };
144
218
  // A task's controls with the effective value of each: an override wins, else the run's value is inherited.
145
219
  function taskValues(controls, id) {
146
220
  const c = obj(controls), t = obj(obj(c.tasks)[id]), run = runValues(c);
@@ -211,7 +285,8 @@
211
285
 
212
286
  /* ---------- the page: drawing ---------- */
213
287
 
214
- const store = { enabled: null, runs: new Map(), models: null, modelsAsked: false, lists: new Set(), recent: new Map() };
288
+ // requests: `<run key>|<task>` → the id of the retry this page queued, until the scheduler's line answers it
289
+ const store = { enabled: null, runs: new Map(), models: null, modelsAsked: false, lists: new Set(), recent: new Map(), requests: new Map() };
215
290
  const RECENT_MS = 9000; // how long a result line stays after the section is rebuilt
216
291
  const STALE_MS = 20000;
217
292
  let seq = 0;
@@ -284,8 +359,9 @@
284
359
  return seg;
285
360
  }
286
361
 
287
- // Ask, send, show the answer. A success redraws every row from the controls the answer carries.
288
- async function commit(view, r, change, fields, button) {
362
+ // Ask, send, show the answer. A success redraws every row from the controls the answer carries; `done` gets a
363
+ // success's answer (a queued retry keeps its request id).
364
+ async function commit(view, r, change, fields, button, done) {
289
365
  const u = ui();
290
366
  if (view.inflight) { u.outcome(r.out, { ok: false, status: 409, error: 'busy' }); return; }
291
367
  clearOutcome(view, r);
@@ -299,8 +375,10 @@
299
375
  const res = await u.post(path(view.run.key), plan.body);
300
376
  view.inflight = false; view.section.removeAttribute('aria-busy');
301
377
  remember(view, r, res, savedText(res.data));
378
+ // sent: the fields are no longer an edit, also when the answer (a queued request) carries no controls
379
+ if (res.ok) for (const f of fields) delete view.draft[f];
380
+ if (res.ok && done) done(res.data);
302
381
  if (res.ok && obj(res.data).controls && res.data.controls.run) {
303
- for (const f of fields) delete view.draft[f];
304
382
  accept(view.run.key, res.data.controls, view.run.updated);
305
383
  draw(view, res.data.controls);
306
384
  } else sync(view);
@@ -332,19 +410,74 @@
332
410
  }
333
411
 
334
412
  function liveLamp(view, c) {
335
- const u = ui(), live = c.live === true, box = u.el('p', undefined, 'ctl-live');
413
+ const u = ui(), live = c.live === true, box = u.el('p', undefined, 'ctl-live'), v = runValues(c);
336
414
  box.append(u.el('i', undefined, 'lamp ' + (live ? 'st-running' : 'st-pending')),
337
- u.el('span', live ? 'live' : 'not running', 'plate'), u.el('span', live ? 'a scheduler is running this plan' : 'no scheduler is running this plan', 'muted'));
415
+ u.el('span', live ? 'live' : 'not running', 'plate'));
416
+ if (live && v.stopping) box.append(u.el('span', v.stopping === 'now' ? 'stopping now' : 'stopping', 'tag ctl-state ctl-stopping'));
417
+ else if (live && v.drained) box.append(u.el('span', 'drained', 'tag ctl-state ctl-drained'));
418
+ box.append(u.el('span', !live ? 'no scheduler is running this plan' : v.stopping ? 'the run ends once its running tasks are done'
419
+ : v.drained ? 'running tasks finish; nothing new starts until resumed' : 'a scheduler is running this plan', 'muted'));
420
+ return box;
421
+ }
422
+
423
+ // Drain or Resume, Stop and Stop now: live only, each asked first.
424
+ function runKeys(view, c) {
425
+ const u = ui(), v = runValues(c), r = shell(view, 'run', 'Run', false);
426
+ const keys = u.el('div', undefined, 'ctl-keys');
427
+ const key = (label, change, title, cls) => {
428
+ const b = u.el('button', label, cls); b.type = 'button'; b.title = title;
429
+ b.addEventListener('click', () => commit(view, r, change, [], b));
430
+ keys.append(b);
431
+ };
432
+ if (v.drained) key('Resume', { kind: 'resume' }, 'Start tasks again');
433
+ else key('Drain', { kind: 'drain' }, 'Start nothing new; running tasks finish and the run waits');
434
+ if (!v.stopping) {
435
+ key('Stop', { kind: 'stop' }, 'Let running tasks finish, then end the run (no verification, unless every task merged)');
436
+ key('Stop now', { kind: 'stop', now: true }, 'Interrupt running tasks and end the run', 'ui-danger');
437
+ }
438
+ r.control.append(keys);
439
+ help(r, v.stopping ? 'Stopping: nothing more to ask of the run.' : 'Stop lets running tasks finish; Stop now interrupts them.');
440
+ return r.box;
441
+ }
442
+
443
+ // One line per infrastructure pause; its countdown ticks from the page's clock between polls.
444
+ function pauseBox(view, c) {
445
+ const u = ui(), box = u.el('div', undefined, 'ctl-pauses');
446
+ box.append(u.el('span', 'Paused on infrastructure', 'ctl-label'));
447
+ const ul = u.el('ul', undefined, 'ctl-pause-list');
448
+ const paint = () => {
449
+ const lines = pauseLines(c, Date.now() / 1000);
450
+ ul.replaceChildren(...lines.map(l => {
451
+ const li = u.el('li', undefined, 'ctl-pause' + (l.exhausted ? ' ctl-exhausted' : ''));
452
+ li.append(u.el('i', undefined, 'lamp ' + (l.exhausted ? 'st-fail' : 'st-running')), u.el('span', l.text, 'ctl-pause-text'));
453
+ return li;
454
+ }));
455
+ };
456
+ paint();
457
+ if (view.ticker) clearInterval(view.ticker);
458
+ view.ticker = setInterval(() => { if (box.isConnected) paint(); else { clearInterval(view.ticker); view.ticker = null; } }, 1000);
459
+ box.append(ul);
338
460
  return box;
339
461
  }
340
462
 
463
+ // An on/off setting as a two-key bank.
464
+ function switchRow(view, c, o) {
465
+ const r = shell(view, o.key, o.label, false), perm = o.perm;
466
+ r.control.append(bank(r, [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }], o.current, perm.enabled, (value, button) => {
467
+ if (value !== o.current) commit(view, r, { kind: 'set', key: o.key, input: value }, [], button);
468
+ }));
469
+ help(r, o.help);
470
+ if (!perm.enabled) why(r, perm.why);
471
+ return r.box;
472
+ }
473
+
341
474
  // An integer or minutes setting: a field and a Set key; Enter sets.
342
475
  function countRow(view, c, o) {
343
476
  const u = ui(), r = shell(view, o.key, o.label, true), perm = o.perm;
344
477
  const form = u.el('form', undefined, 'ctl-set');
345
478
  form.noValidate = true;
346
479
  const input = u.el('input');
347
- input.type = 'text'; input.inputMode = o.decimal ? 'decimal' : 'numeric'; input.autocomplete = 'off'; input.spellcheck = false;
480
+ input.type = 'text'; input.inputMode = o.text ? 'text' : o.decimal ? 'decimal' : 'numeric'; input.autocomplete = 'off'; input.spellcheck = false;
348
481
  input.id = r.id + '-in'; input.placeholder = o.placeholder || ''; input.disabled = !perm.enabled;
349
482
  const name = o.key + (o.task ? '@' + o.task : '');
350
483
  input.value = field(view, name, o.value === null ? '' : String(o.value));
@@ -403,6 +536,10 @@
403
536
  const v = runValues(c), body = ui().el('div', undefined, 'ctl-body');
404
537
  const perm = key => canEdit(c, key);
405
538
  body.append(liveLamp(view, c));
539
+ if (c.live === true) {
540
+ body.append(runKeys(view, c));
541
+ if (v.pauses.length) body.append(pauseBox(view, c));
542
+ }
406
543
  body.append(levelRow(view, c, { key: 'qa-threshold', label: 'QA threshold', current: v.qa, perm: perm('qa-threshold'),
407
544
  help: v.qa === 'none' ? 'The least severe finding that still blocks: no threshold is set.'
408
545
  : `The least severe finding that still blocks: ${v.qa} (${LEVELS.slice(0, LEVELS.indexOf(v.qa) + 1).join(', ')} block).`, noneTitle: 'No threshold' }));
@@ -416,6 +553,13 @@
416
553
  help: 'Retries a task may use, run-wide.' }));
417
554
  body.append(countRow(view, c, { key: 'infra-retries', label: 'Infra retries', unit: 'retries', value: v.infraBudget, placeholder: 'not set', perm: perm('infra-retries'),
418
555
  help: 'Retries for infrastructure failures (auth, quota, network), run-wide.' }));
556
+ body.append(countRow(view, c, { key: 'max-parallel', label: 'Max parallel', unit: 'tasks', value: v.maxParallel, placeholder: 'as started', perm: perm('max-parallel'),
557
+ help: 'Tasks running at once, 1 to 64. A lower limit lets running tasks finish; it never stops one.' }));
558
+ body.append(countRow(view, c, { key: 'infra-backoff', label: 'Infra backoff', text: true, value: v.infraBackoff, placeholder: 'exp:60', perm: perm('infra-backoff'),
559
+ help: `The wait between infrastructure retries: ${BACKOFF_FORMS}. "default" goes back to exp:60.` }));
560
+ body.append(switchRow(view, c, { key: 'escalate-effort', label: 'Effort step', current: v.escalateEffort, perm: perm('escalate-effort'),
561
+ help: v.escalateEffort === 'on' ? 'On: at the top tier, a task is retried once more at the next effort its model accepts before it stops.'
562
+ : 'Off: at the top tier, a task that used up its escalation stops with a split suggestion.' }));
419
563
  body.append(changeLog(c));
420
564
  return body;
421
565
  }
@@ -435,9 +579,66 @@
435
579
  inherit: rb.mode === 'inherit' ? 'pressed' : 'ready', perm: canEdit(c, 'retry-budget', id),
436
580
  help: `${rb.mode === 'inherit' ? (rb.effective === null ? 'Inherits the run\'s budget.' : `Inherits ${rb.effective} from the run.`) : `${rb.own} for this task.`} ${used}.` }));
437
581
  if (!canEdit(c, 'pin', id).hidden) body.append(pinRow(view, c, v));
582
+ if (canRetry(c, id) || store.requests.has(view.run.key + '|' + id)) body.append(retryRow(view, c, v));
438
583
  return body;
439
584
  }
440
585
 
586
+ // The implementer picker the pin and Retry now share: alias (suggestions from the models seen in runs, free text too),
587
+ // effort, optional harness. Drafts live under `<prefix>.alias|effort|harness`; read() gives the change's parts.
588
+ function modelFields(view, r, prefix) {
589
+ const u = ui(), alias = u.el('input'), harness = u.el('input'), list = u.el('datalist');
590
+ alias.type = 'text'; harness.type = 'text';
591
+ for (const i of [alias, harness]) { i.autocomplete = 'off'; i.spellcheck = false; }
592
+ alias.setAttribute('list', r.id + '-models');
593
+ list.id = r.id + '-models'; fillList(list);
594
+ if (!store.models) trackList(list);
595
+ alias.value = field(view, prefix + '.alias', ''); harness.value = field(view, prefix + '.harness', '');
596
+ alias.addEventListener('input', () => touch(view, prefix + '.alias', alias.value));
597
+ harness.addEventListener('input', () => touch(view, prefix + '.harness', harness.value));
598
+ const captioned = (caption, control, cls) => { const l = u.el('label', undefined, 'filter ' + cls); l.append(u.el('span', caption), control); return l; };
599
+ const effortCaption = u.el('span', 'Effort'), effortBox = u.el('div', undefined, 'filter ctl-effort');
600
+ effortCaption.id = r.id + '-effort';
601
+ const effortBank = bank(r, EFFORTS.map(e => ({ value: e, label: e })), field(view, prefix + '.effort', 'default'), true, value => {
602
+ touch(view, prefix + '.effort', value);
603
+ for (const b of effortBank.children) b.setAttribute('aria-pressed', String(b.dataset.value === value));
604
+ });
605
+ effortBank.setAttribute('aria-labelledby', effortCaption.id);
606
+ effortBox.append(effortCaption, effortBank);
607
+ loadModels();
608
+ return { nodes: [captioned('Model alias', alias, 'ctl-alias'), list, captioned('Harness (optional)', harness, 'ctl-harness'), effortBox],
609
+ read: () => ({ alias: alias.value, effort: view.draft[prefix + '.effort'] || 'default', harness: harness.value }),
610
+ fields: [prefix + '.alias', prefix + '.effort', prefix + '.harness'] };
611
+ }
612
+
613
+ // Retry now: queue a retry of a failed or stopped task into the live run, on its policy's model or one named here.
614
+ function retryRow(view, c, v) {
615
+ const u = ui(), r = shell(view, 'retry', 'Retry now', false), id = v.id, hold = view.run.key + '|' + id;
616
+ const request = store.requests.get(hold);
617
+ if (request) {
618
+ const st = requestState(request, c.log);
619
+ const line = u.el('p', undefined, 'ctl-request ctl-request-' + st.state);
620
+ line.append(u.el('i', undefined, 'lamp ' + (st.state === 'applied' ? 'st-pass' : st.state === 'refused' ? 'st-fail' : 'st-running')),
621
+ u.el('span', requestText(st)));
622
+ r.status.append(line);
623
+ }
624
+ if (!canRetry(c, id)) return r.box;
625
+ const form = u.el('form', undefined, 'ctl-pin ctl-retry');
626
+ form.noValidate = true;
627
+ const picker = modelFields(view, r, 'retry');
628
+ const go = u.el('button', 'Retry now', 'primary'); go.type = 'submit';
629
+ form.append(...picker.nodes, go);
630
+ form.addEventListener('submit', e => {
631
+ e.preventDefault();
632
+ commit(view, r, Object.assign({ kind: 'retry', task: id }, picker.read()), picker.fields, go, data => {
633
+ if (data && data.queued === true && typeof data.request === 'string') store.requests.set(hold, data.request);
634
+ show(view);
635
+ });
636
+ });
637
+ r.control.append(form);
638
+ help(r, 'Leave the model empty for the escalation policy\'s choice.');
639
+ return r.box;
640
+ }
641
+
441
642
  function pinRow(view, c, v) {
442
643
  const u = ui(), r = shell(view, 'pin', 'Pin next retry to', false), id = v.id;
443
644
  const now = u.el('div', undefined, 'ctl-pin-now');
@@ -455,39 +656,21 @@
455
656
  }
456
657
  if (v.stop) {
457
658
  const stop = u.el('p', undefined, 'ctl-stop');
458
- stop.append(u.el('i', undefined, 'lamp st-fail'), u.el('span', 'policy stopped: split or pin a model'));
659
+ // "Retry now" is only offered while the run is live
660
+ stop.append(u.el('i', undefined, 'lamp st-fail'), u.el('span', canRetry(c, id) ? 'policy stopped: split, pin a model, or Retry now' : 'policy stopped: split or pin a model'));
459
661
  r.status.append(stop);
460
662
  }
461
- // alias (suggestions from the models seen in runs, free text too), effort, optional harness
462
663
  const form = u.el('form', undefined, 'ctl-pin');
463
664
  form.noValidate = true;
464
- const alias = u.el('input'), harness = u.el('input'), list = u.el('datalist');
465
- alias.type = 'text'; harness.type = 'text';
466
- for (const i of [alias, harness]) { i.autocomplete = 'off'; i.spellcheck = false; }
467
- alias.setAttribute('list', r.id + '-models');
468
- list.id = r.id + '-models'; fillList(list);
469
- if (!store.models) trackList(list);
470
- alias.value = field(view, 'pin.alias', ''); harness.value = field(view, 'pin.harness', '');
471
- alias.addEventListener('input', () => touch(view, 'pin.alias', alias.value));
472
- harness.addEventListener('input', () => touch(view, 'pin.harness', harness.value));
473
- const captioned = (caption, control, cls) => { const l = u.el('label', undefined, 'filter ' + cls); l.append(u.el('span', caption), control); return l; };
474
- const effortCaption = u.el('span', 'Effort'), effortBox = u.el('div', undefined, 'filter ctl-effort');
475
- effortCaption.id = r.id + '-effort';
476
- const effortBank = bank(r, EFFORTS.map(e => ({ value: e, label: e })), field(view, 'pin.effort', 'default'), true, value => {
477
- touch(view, 'pin.effort', value);
478
- for (const b of effortBank.children) b.setAttribute('aria-pressed', String(b.dataset.value === value));
479
- });
480
- effortBank.setAttribute('aria-labelledby', effortCaption.id);
481
- effortBox.append(effortCaption, effortBank);
665
+ const picker = modelFields(view, r, 'pin');
482
666
  const go = u.el('button', 'Pin', 'primary'); go.type = 'submit';
483
- form.append(captioned('Model alias', alias, 'ctl-alias'), list, captioned('Harness (optional)', harness, 'ctl-harness'), effortBox, go);
667
+ form.append(...picker.nodes, go);
484
668
  form.addEventListener('submit', e => {
485
669
  e.preventDefault();
486
- commit(view, r, { kind: 'pin', task: id, alias: alias.value, effort: view.draft['pin.effort'] || 'default', harness: harness.value }, ['pin.alias', 'pin.effort', 'pin.harness'], go);
670
+ commit(view, r, Object.assign({ kind: 'pin', task: id }, picker.read()), picker.fields, go);
487
671
  });
488
672
  r.control.append(form);
489
673
  help(r, 'Applies at the next full retry, not a QA-only resume.');
490
- loadModels();
491
674
  return r.box;
492
675
  }
493
676
  function fillList(list) {
@@ -615,5 +798,6 @@
615
798
  }
616
799
 
617
800
  return { LEVELS, EFFORTS, KEYS, specOf, countOf, secondsOf, minutesOf, valueFor, bodyFor, prepare, savedText, effective, canEdit, logLine, agoText,
618
- aliasesOf, scopeOf, typing, isEditing, keepEditing, redrawDue, mountRun, mountTask, hold };
801
+ aliasesOf, scopeOf, typing, isEditing, keepEditing, redrawDue, countdown, pauseLines, requestState, requestText, canRetry,
802
+ mountRun, mountTask, hold };
619
803
  });
@@ -20,10 +20,13 @@ window.ForgeGantt = (() => {
20
20
  const X = RM.execModel(run, { now, ghosts });
21
21
  const all = X.items.concat(X.extra);
22
22
  if (!all.length) { const p = document.createElement('p'); p.className = 'fg-empty'; p.textContent = 'Nothing has run yet: no attempt timings are recorded for this run.'; container.append(p); return; }
23
- const width = Math.max(640, container.clientWidth || 900), plot = width - LABEL - 12;
24
- const xs = t => LABEL + (t - X.t0) / (X.t1 - X.t0) * plot;
25
- const laneOf = it => typeof it.lane === 'number' ? it.lane : X.lanes + (it.kind === 'verification' ? 0 : X.finishLanes.length > 1 ? 1 : 0);
23
+ const laneOf = it => typeof it.lane === 'number' ? it.lane : X.lanes + Number(String(it.lane).slice(1));
26
24
  const rows = X.lanes + X.finishLanes.length;
25
+ const laneName = i => { const fl = X.finishLanes[i - X.lanes]; return i < X.lanes ? `lane ${i + 1}${i >= X.cap ? ' !' : ''}` : fl.label || fl.name.toLowerCase(); };
26
+ // the label column fits the longest row name ("verify · <lane>" can be long)
27
+ const lw = Math.max(LABEL, Math.ceil(12 + 6.6 * Math.max(...Array.from({ length: rows }, (_, i) => laneName(i).length))));
28
+ const width = Math.max(640, container.clientWidth || 900), plot = width - lw - 12;
29
+ const xs = t => lw + (t - X.t0) / (X.t1 - X.t0) * plot;
27
30
  const height = TOP + rows * LANE + 8;
28
31
  const root = svg('svg', { class: 'gantt', viewBox: `0 0 ${width} ${height}`, width, height, role: 'group', 'aria-label': 'Attempts on wall-clock time' });
29
32
  for (const r of X.ruler) {
@@ -32,8 +35,8 @@ window.ForgeGantt = (() => {
32
35
  }
33
36
  for (let i = 0; i < rows; i++) {
34
37
  const over = i < X.lanes && i >= X.cap, y = TOP + i * LANE;
35
- svg('rect', { class: 'gt-lane' + (over ? ' gt-over' : ''), x: LABEL, y: y + 2, width: plot, height: LANE - 4, rx: 4 }, root);
36
- const name = i < X.lanes ? `lane ${i + 1}${over ? ' !' : ''}` : X.finishLanes[i - X.lanes].name.toLowerCase();
38
+ svg('rect', { class: 'gt-lane' + (over ? ' gt-over' : ''), x: lw, y: y + 2, width: plot, height: LANE - 4, rx: 4 }, root);
39
+ const name = laneName(i);
37
40
  const t = svg('text', { class: 'gt-lane-label' + (over ? ' gt-over' : ''), x: 6, y: y + LANE / 2 + 4 }, root); t.textContent = name;
38
41
  if (over) svg('title', {}, t).textContent = `More attempts overlapped than --max-parallel ${X.cap} allows: separate run invocations, or a resumed run.`;
39
42
  }
@@ -41,19 +44,21 @@ window.ForgeGantt = (() => {
41
44
  for (const it of all) {
42
45
  const y = TOP + laneOf(it) * LANE + 5, x = xs(it.start), w = Math.max(3, xs(it.end) - x);
43
46
  const future = replay !== null && it.start > replay;
44
- const g = svg('g', { class: `gt-item ${stc(it.status)}${it.ghost ? ' gt-ghost' : ''}${future ? ' gt-future' : ''}${critSet.has(it.task) && !it.ghost ? ' gt-crit' : ''}${sel && (sel.exec === it.id || (sel.kind === 'task' && sel.id === it.task)) ? ' gt-sel' : ''}`, tabindex: 0, role: 'button' }, root);
47
+ const picked = sel && (sel.exec === it.id || (sel.kind === 'task' && sel.id === it.task) || (sel.kind === 'verification' && it.kind === 'verification'));
48
+ const g = svg('g', { class: `gt-item ${stc(it.status)}${it.ghost ? ' gt-ghost' : ''}${it.approx ? ' gt-approx' : ''}${it.rerun ? ' gt-rerun' : ''}${future ? ' gt-future' : ''}${critSet.has(it.task) && !it.ghost ? ' gt-crit' : ''}${picked ? ' gt-sel' : ''}`, tabindex: 0, role: 'button' }, root);
45
49
  svg('rect', { class: 'gt-box', x, y, width: w, height: LANE - 10, rx: 3 }, g);
46
50
  for (const s of it.segs) {
47
51
  const sx = xs(s.start), sw = Math.max(1.5, xs(s.end ?? it.end) - sx);
48
52
  svg('rect', { class: `gt-seg gt-${s.role} ${stc(s.status)}`, x: sx, y: y + LANE - 17, width: sw, height: 5, rx: 1.5 }, g);
49
53
  }
50
- const label = it.kind === 'verification' ? 'verify' : it.kind === 'review' ? 'review' : `${it.task}${it.attempt ? ' A' + it.attempt : ''}`;
54
+ const label = it.vlane ? (it.rerun ? `rerun ${it.k - 1}` : `run ${it.k}`) : it.kind === 'verification' ? (it.rerun ? 'rerun' : 'verify') : it.kind === 'review' ? 'review' : `${it.task}${it.attempt ? ' A' + it.attempt : ''}`;
51
55
  if (w > label.length * 6.4 + 6) svg('text', { class: 'gt-label', x: x + 4, y: y + 11 }, g).textContent = label;
52
- svg('title', {}, g).textContent = `${label} · ${it.status} · ${RM.fmtDur(it.end - it.start)}${it.ghost ? ' (projected)' : ''}`;
56
+ svg('title', {}, g).textContent = it.title ? `${it.title} · ${it.status} · ${RM.fmtDur(it.end - it.start)}`
57
+ : `${label} · ${it.status} · ${RM.fmtDur(it.end - it.start)}${it.ghost ? ' (projected)' : ''}`;
53
58
  const pick = () => {
54
59
  if (!onSelect) return;
55
60
  if (it.kind === 'review') onSelect({ kind: 'final', name: it.review });
56
- else if (it.kind === 'verification') onSelect({ kind: 'execution', task: null, exec: it.id });
61
+ else if (it.kind === 'verification') onSelect(it.vlane || it.approx ? { kind: 'verification' } : { kind: 'execution', task: null, exec: it.id });
57
62
  else if (it.ghost || String(it.id).startsWith('span:')) onSelect({ kind: 'task', id: it.task });
58
63
  else onSelect({ kind: 'execution', task: it.task, id: it.task, exec: it.id });
59
64
  };
@@ -25,6 +25,7 @@
25
25
  <script src="graph.js" defer></script>
26
26
  <script src="gantt.js" defer></script>
27
27
  <script src="controls.js" defer></script>
28
+ <script src="verifyview.js" defer></script>
28
29
  <script src="panel.js" defer></script>
29
30
  <script src="diffview.js" defer></script>
30
31
  <script src="convo.js" defer></script>
@@ -559,10 +559,11 @@ window.ForgePanel = (() => {
559
559
  head.append(el('h2', 'Verification'), el('div', 'finish', 'insp-kind'), chip(v && v.status || 'pending'));
560
560
  host.append(head);
561
561
  if (!v) { muted(host, 'Verification has not run: it runs after every task is merged into the integration branch.'); return; }
562
+ if (window.ForgeVerify) window.ForgeVerify.render(host, ctx); // live state, lanes and failing tests
562
563
  fields(host, [['Command', v.command, true], ['Commit', v.commit, true]]);
563
564
  if (list(v.runs).length) {
564
565
  const ul = el('ul', undefined, 'plain');
565
- for (const r of v.runs) ul.append(el('li', `#${display(r.n)} ${display(r.kind)} · exit ${display(r.exit)} · ${RM.fmtDur(r.duration_s)}${r.tree_changed ? ' · changed the tree' : ''}`));
566
+ for (const r of v.runs) ul.append(el('li', `#${display(r.n)} ${display(r.kind)}${r.lane ? ' · ' + r.lane : ''} · exit ${display(r.exit)} · ${RM.fmtDur(r.duration_s)}${r.tree_changed ? ' · changed the tree' : ''}`));
566
567
  section(host, 'Runs').append(ul);
567
568
  }
568
569
  for (const x of list(run.timeline && run.timeline.verifications)) { const s = { kind: 'execution', task: null, exec: x.id }; host.append(el('p'), link(`Timings of the verification started ${time(x.started)} →`, ctx.hash(s), () => ctx.onSelect(s))); }
@@ -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
  });