@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
@@ -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
  });
@@ -64,6 +64,20 @@
64
64
  const note = n > MAX_FPS ? `${n} selected: at most ${MAX_FPS} per action` : '';
65
65
  return { fps, count: n, enabled: n >= 1 && n <= MAX_FPS, note };
66
66
  }
67
+ // Fingerprints "Select shown open" adds. `rows` are the rows the filters leave shown. The cap is how many of
68
+ // those are already selected and open — the same rows File and Dismiss take — so a selection a filter hides stays
69
+ // selected and does not count. A shown row that is not open is not added.
70
+ function selectShownOpen(rows, selected) {
71
+ const chosen = new Set(selected instanceof Set || Array.isArray(selected) ? selected : []);
72
+ const add = [];
73
+ for (const r of list(rows)) {
74
+ if (!selectable('file', r) || chosen.has(r.fp)) continue;
75
+ if (selection('file', rows, chosen).count >= MAX_FPS) break;
76
+ chosen.add(r.fp);
77
+ add.push(r.fp);
78
+ }
79
+ return add;
80
+ }
67
81
  const bit = v => v ? '1' : '0';
68
82
  // The GET path: `o` = { scope, offline, plausible, dismissed, only (fingerprints) }.
69
83
  function issuesUrl(key, o) {
@@ -104,6 +118,11 @@
104
118
  return { kind: 'note', text: c && c.note ? c.note : 'no detail' };
105
119
  });
106
120
  }
121
+ // The CLI's `notes` as lines for the dialog, only when the POST created nothing: a bare "0 created" says no reason, the
122
+ // notes ("--only fp: no such open finding") do. When something was created the notes are not about the dialog.
123
+ function emptyNotes(created, notes) {
124
+ return list(created).some(c => c && c.url) ? [] : list(notes).map(n => String(n)).filter(Boolean);
125
+ }
107
126
  // An untrusted URL becomes a link only when it is a plain https address.
108
127
  const safeUrl = u => typeof u === 'string' && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : null;
109
128
  // The marker for a finding whose whole-run review is outdated, or null. The lamp is the hollow unlit
@@ -176,7 +195,7 @@
176
195
  const dismissKey = key_('Dismiss', undefined, () => openDismiss());
177
196
  const undismissKey = key_('Undismiss', undefined, () => doDismiss(true, ''));
178
197
  const allKey = key_('Select shown open', undefined, () => {
179
- for (const r of shown()) if (selectable('file', r) && s.selected.size < MAX_FPS) s.selected.add(r.fp);
198
+ for (const fp of selectShownOpen(shown(), s.selected)) s.selected.add(fp);
180
199
  drawTable(); syncActions();
181
200
  });
182
201
  const noneKey = key_('Clear selection', undefined, () => { s.selected.clear(); drawTable(); syncActions(); });
@@ -193,6 +212,9 @@
193
212
  host.append(dlg);
194
213
 
195
214
  const shown = () => filterRows(s.rows, { ...s.filter, dismissed: s.dismissed });
215
+ // What an action takes: the selected rows the filters leave visible. Rows hidden by a filter stay in s.selected, so
216
+ // they come back with the filter, but no button counts them and no action reaches them.
217
+ const picked = action => selection(action, shown(), s.selected);
196
218
  function syncScope() {
197
219
  for (const [v, b] of Object.entries(scopeKeys)) b.setAttribute('aria-pressed', String(s.scope === v));
198
220
  lede.textContent = s.scope === 'run' ? `Findings this run left unfixed${ctx.run && ctx.run.goal ? ': ' + String(ctx.run.goal).split('\n')[0] : ''}.`
@@ -201,11 +223,11 @@
201
223
  function syncActions() {
202
224
  actions.hidden = !s.writes;
203
225
  if (!s.writes) return;
204
- const f = selection('file', s.rows, s.selected), d = selection('dismiss', s.rows, s.selected), u = selection('undismiss', s.rows, s.selected);
226
+ const f = picked('file'), d = picked('dismiss'), u = picked('undismiss');
205
227
  fileKey.textContent = f.count ? `File ${f.count} issue${f.count === 1 ? '' : 's'}` : 'File issues';
206
228
  fileKey.disabled = !f.enabled || s.busy; dismissKey.disabled = !d.enabled || s.busy;
207
229
  undismissKey.textContent = u.count ? `Undismiss ${u.count}` : 'Undismiss'; undismissKey.disabled = !u.enabled || s.busy;
208
- actionNote.textContent = f.note || d.note || u.note || (s.selected.size ? `${f.count} open, ${u.count} dismissed selected` : '');
230
+ actionNote.textContent = f.note || d.note || u.note || (f.count + u.count ? `${f.count} open, ${u.count} dismissed selected` : '');
209
231
  }
210
232
  function setLamp(cls, text) {
211
233
  lamp.replaceChildren(el('i', undefined, 'lamp ' + cls), el('span', text));
@@ -330,7 +352,7 @@
330
352
  // dismiss: an optional reason, then the call
331
353
  async function openDismiss() {
332
354
  reasonHost.replaceChildren();
333
- const sel = selection('dismiss', s.rows, s.selected);
355
+ const sel = picked('dismiss');
334
356
  if (!sel.enabled) return;
335
357
  const l = el('label', undefined, 'fnd-field fnd-reason-field'), input = el('input');
336
358
  input.type = 'text'; input.maxLength = MAX_REASON; input.placeholder = 'why (optional)';
@@ -345,7 +367,7 @@
345
367
  input.focus();
346
368
  }
347
369
  async function doDismiss(undo, reason) {
348
- const sel = selection(undo ? 'undismiss' : 'dismiss', s.rows, s.selected);
370
+ const sel = picked(undo ? 'undismiss' : 'dismiss');
349
371
  if (!sel.enabled || s.busy) return;
350
372
  s.busy = true; syncActions(); outcomeHost.replaceChildren();
351
373
  const res = await UI.post(dismissPath(key), dismissBody(sel.fps, reason, undo));
@@ -356,17 +378,19 @@
356
378
 
357
379
  // filing: drafts for exactly the selection, a preview, then the POST
358
380
  async function openFile() {
359
- const sel = selection('file', s.rows, s.selected);
381
+ const sel = picked('file');
360
382
  if (!sel.enabled || s.busy) return;
361
383
  s.busy = true; syncActions(); outcomeHost.replaceChildren();
362
384
  setLamp('st-running', 'preparing the issue drafts…');
363
- const res = await UI.get(issuesUrl(key, { scope: s.scope, offline: false, plausible: s.plausible, only: sel.fps }));
385
+ // The scope and the toggle stay usable while the drafts are made: the preview files what these drafts were made for.
386
+ const scope = s.scope, plausible = s.plausible;
387
+ const res = await UI.get(issuesUrl(key, { scope, offline: false, plausible, only: sel.fps }));
364
388
  s.busy = false; syncActions();
365
389
  if (!res.ok) { setLamp('st-fail', 'could not prepare the drafts'); UI.outcome(outcomeHost, res); return; }
366
390
  setLamp('st-pass', 'drafts ready');
367
- preview(sel.fps, list(res.data.drafts));
391
+ preview(sel.fps, list(res.data.drafts), { scope, plausible });
368
392
  }
369
- function preview(fps, drafts) {
393
+ function preview(fps, drafts, made) {
370
394
  dlg.replaceChildren();
371
395
  dlg.append(el('h2', 'File issues on GitHub'));
372
396
  const warn = el('p', undefined, 'fnd-warn');
@@ -399,7 +423,7 @@
399
423
  labelNote.hidden = !parsed.error; labelNote.textContent = parsed.error || '';
400
424
  if (parsed.error) return;
401
425
  go.disabled = true; cancel.disabled = true; labels.disabled = true; results.replaceChildren(); posting = true;
402
- const res = await UI.post(issuesPath(key), fileBody(s.scope, fps, s.plausible, parsed.labels));
426
+ const res = await UI.post(issuesPath(key), fileBody(made.scope, fps, made.plausible, parsed.labels));
403
427
  posting = false;
404
428
  if (!res.ok) { go.disabled = false; cancel.disabled = false; labels.disabled = false; UI.outcome(results, res); return; }
405
429
  const lines = resultLines(res.data.created), ul = el('ul', undefined, 'fnd-created');
@@ -410,6 +434,7 @@
410
434
  ul.append(li);
411
435
  }
412
436
  results.replaceChildren(el('p', `${lines.filter(l => l.kind === 'created').length} created`, 'fnd-done'), ul);
437
+ for (const n of emptyNotes(res.data.created, res.data.notes)) results.append(el('p', n, 'muted fnd-result-note'));
413
438
  go.remove(); cancel.textContent = 'Close'; cancel.disabled = false;
414
439
  for (const fp of fps) s.selected.delete(fp);
415
440
  load();
@@ -433,6 +458,6 @@
433
458
  return { reload: load };
434
459
  }
435
460
 
436
- return { render, statusOf, filterRows, mergeFiled, selectable, selection, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
437
- parseLabels, resultLines, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
461
+ return { render, statusOf, filterRows, mergeFiled, selectable, selection, selectShownOpen, issuesUrl, issuesPath, dismissPath, fileBody, dismissBody,
462
+ parseLabels, resultLines, emptyNotes, safeUrl, reviewMarker, ORIGINS, MAX_FPS };
438
463
  });
@@ -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))); }