@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.
- package/CHANGELOG.md +47 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +46 -12
- package/docs/commands.md +48 -9
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-issues.py +1 -1
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +19 -4
- package/ui/docs/api.md +139 -25
- package/ui/forge_ui/control.py +26 -7
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/findingsview.js +37 -12
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +60 -0
- 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)'
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
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(
|
|
667
|
+
form.append(...picker.nodes, go);
|
|
484
668
|
form.addEventListener('submit', e => {
|
|
485
669
|
e.preventDefault();
|
|
486
|
-
commit(view, r, { kind: 'pin', task: id
|
|
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,
|
|
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
|
|
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 =
|
|
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 || (
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
36
|
-
const name = i
|
|
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
|
|
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 = `${
|
|
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))); }
|