@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.
- package/CHANGELOG.md +34 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +44 -11
- package/docs/commands.md +45 -7
- 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-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-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 +18 -3
- package/ui/docs/api.md +126 -17
- package/ui/forge_ui/control.py +12 -2
- 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/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/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
|
});
|
|
@@ -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))); }
|
|
@@ -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.
|
|
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
|
-
|
|
519
|
-
finishLanes
|
|
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(([
|
|
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
|
|
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
|
});
|