@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
|
@@ -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
|
});
|
|
@@ -289,7 +289,11 @@ export async function createStage(host, cb = {}) {
|
|
|
289
289
|
function finishInfo(id) {
|
|
290
290
|
const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
|
|
291
291
|
if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
|
|
292
|
-
if (id === 'f:verification')
|
|
292
|
+
if (id === 'f:verification') {
|
|
293
|
+
const V = F.verification, n = V.lanes.length, plural = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`;
|
|
294
|
+
const line = V.mode === 'lanes' ? `${plural(n, 'lane')} · ${plural(V.reruns, 'rerun')}` : V.runs ? plural(V.runs, 'run') : 'not run yet';
|
|
295
|
+
return { title: 'VERIFICATION', sub: V.command || (V.mode === 'lanes' ? V.lanes.map(l => l.name).join(' · ') : 'no command recorded'), status: V.status, line };
|
|
296
|
+
}
|
|
293
297
|
const status = finishStatus(id, { status: F.final.status });
|
|
294
298
|
const src = status === 'OUTDATED' && st.replay !== null ? (RM.reviewAt(run, st.replay) || F.final) : F.final;
|
|
295
299
|
const f = F.final.findings;
|
|
@@ -313,7 +317,7 @@ export async function createStage(host, cb = {}) {
|
|
|
313
317
|
}, 'cover', { x: x + 12, y: y + 8, z: h });
|
|
314
318
|
if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
|
|
315
319
|
else if (m.id === 'f:integration') leds(el, Math.min(40, list(st.run.tasks).length), x + 17, y + d - 13, h + .16, { step: 8, s: 2.4, perRow: 22 });
|
|
316
|
-
else leds(el, Math.max(1, Math.min(12,
|
|
320
|
+
else leds(el, Math.max(1, Math.min(12, RM.verifyLeds(st.run).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
|
|
317
321
|
finishLeds(el);
|
|
318
322
|
display(el, e => finishInfo(e.id).line || '', { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
|
|
319
323
|
halo(el);
|
|
@@ -326,7 +330,7 @@ export async function createStage(host, cb = {}) {
|
|
|
326
330
|
function finishLeds(el) {
|
|
327
331
|
if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
|
|
328
332
|
else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => { const s = taskStatus(t); return s === 'MERGED' || s === 'ACCEPTED' ? s : null; }));
|
|
329
|
-
else setLeds(el,
|
|
333
|
+
else setLeds(el, RM.verifyLeds(st.run, st.replay !== null ? st.replayState : null));
|
|
330
334
|
}
|
|
331
335
|
function buildPlan(g) {
|
|
332
336
|
const P = RM.planModel(st.run); st.plan = P;
|
|
@@ -512,7 +516,7 @@ export async function createStage(host, cb = {}) {
|
|
|
512
516
|
const lw = Math.max(10, it.w - 10);
|
|
513
517
|
el.roof = label(el, lw, it.d - 14, 4, (cx, s, C, e) => {
|
|
514
518
|
const item = e.data.item, sel = e.cur.sel > .5;
|
|
515
|
-
const title = item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
|
|
519
|
+
const title = item.vlane ? 'verify · ' + item.vlane : item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
|
|
516
520
|
const f = Math.min(11, Math.max(4, (lw - 4) / Math.max(4, title.length * E.CW)));
|
|
517
521
|
E.font(cx, f, s, 600); cx.fillStyle = sel ? C.accHi : item.ghost ? C.ink : C.inkHi; cx.fillText(E.ellipsis(title, Math.floor((lw - 2) / (f * E.CW))), 1 * s, (f + 1) * s);
|
|
518
522
|
// segments: when the dwarf and the review ran inside this attempt
|
|
@@ -1135,7 +1139,7 @@ export async function createStage(host, cb = {}) {
|
|
|
1135
1139
|
if (el.kind === 'finish') return { kind: el.id === 'f:final' ? 'final' : el.id === 'f:verification' ? 'verification' : 'integration' };
|
|
1136
1140
|
const it = el.data.item; if (!it) return null;
|
|
1137
1141
|
if (it.kind === 'review') return { kind: 'final', name: it.review };
|
|
1138
|
-
if (it.kind === 'verification') return { kind: 'execution', task: null, exec: it.id };
|
|
1142
|
+
if (it.kind === 'verification') return it.vlane || it.approx ? { kind: 'verification' } : { kind: 'execution', task: null, exec: it.id };
|
|
1139
1143
|
if (it.ghost || String(it.id).startsWith('span:')) return { kind: 'task', id: it.task };
|
|
1140
1144
|
return { kind: 'execution', task: it.task, id: it.task, exec: it.id };
|
|
1141
1145
|
}
|
|
@@ -85,13 +85,25 @@
|
|
|
85
85
|
return { editor: { base: fresh, draft: e.draft }, dirty: true, moved: norm(fresh) !== norm(e.base) };
|
|
86
86
|
}
|
|
87
87
|
const edited = (editor, draft) => ({ base: editor.base, draft: text(draft) });
|
|
88
|
+
// The editor after typing in a textarea drawn from the file text `from`. When a read moved `base` on since the textarea
|
|
89
|
+
// was drawn (a held body is not redrawn), the typing sits on the old text, so the save would overwrite the new file.
|
|
90
|
+
function typed(editor, draft, from) {
|
|
91
|
+
const e = edited(editor, draft), dirty = isDirty(e);
|
|
92
|
+
return { editor: e, dirty, moved: dirty && norm(from) !== norm(e.base) };
|
|
93
|
+
}
|
|
88
94
|
const shown = editor => editor.draft !== null ? editor.draft : editor.base;
|
|
95
|
+
// What a failed GET /api/config does: with a good read of this run on the page, it stays and a note names the
|
|
96
|
+
// failure; with none yet, the message replaces the page. Status 0 is no answer at all.
|
|
97
|
+
function readFailure(res, hasData) {
|
|
98
|
+
const message = (res && res.status !== 0 && (res.detail || res.error)) || 'could not reach forge-ui';
|
|
99
|
+
return { keep: !!hasData, message };
|
|
100
|
+
}
|
|
89
101
|
|
|
90
102
|
/* ---------- DOM ---------- */
|
|
91
103
|
function render(host, ctx) {
|
|
92
104
|
const UI = root.ForgeUI, c = ctx || {};
|
|
93
105
|
const el = (tag, t, cls) => UI.el(tag, t, cls);
|
|
94
|
-
const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false };
|
|
106
|
+
const s = { run: null, data: null, dataText: null, error: null, writes: false, loading: true, editors: {}, moved: {}, hold: 0, redraw: false, seq: 0, gen: 0, runsSig: null, ready: false, loadedAt: 0, repo: null, gone: false, asking: 0, asks: 0, pickerLate: false };
|
|
95
107
|
const key_ = (label, cls, onclick) => { const b = el('button', label, cls); b.type = 'button'; if (onclick) b.addEventListener('click', onclick); return b; };
|
|
96
108
|
|
|
97
109
|
const head = el('header', undefined, 'set-head');
|
|
@@ -101,10 +113,17 @@
|
|
|
101
113
|
const body = el('div', undefined, 'set-body');
|
|
102
114
|
const note = el('p', 'This page is read-only: this server has no write token, or has controls off.', 'muted set-readonly');
|
|
103
115
|
note.hidden = true;
|
|
104
|
-
|
|
116
|
+
// A failed read over a good one: the page stays, and this says the shown values may be old. Outside the body, so a
|
|
117
|
+
// held body (an open confirm) still gets it.
|
|
118
|
+
const stale = el('p', undefined, 'warn set-problem set-stale'); stale.setAttribute('role', 'status');
|
|
119
|
+
stale.hidden = true;
|
|
120
|
+
host.replaceChildren(head, picker, note, stale, body);
|
|
121
|
+
const staleNote = msg => { stale.hidden = !msg; stale.textContent = msg ? `The last read of the configuration failed: ${msg}. This shows the read before it; the next refresh tries again.` : ''; };
|
|
105
122
|
|
|
106
123
|
/* the repository picker */
|
|
107
124
|
function drawPicker() {
|
|
125
|
+
// The switch confirm lives in the picker: while it is open the rebuild waits, and runs when it is answered.
|
|
126
|
+
if (s.asking) { s.pickerLate = true; return; }
|
|
108
127
|
const runs = c.runs ? c.runs() : [];
|
|
109
128
|
const groups = groupRuns(runs);
|
|
110
129
|
const before = s.run, usable = withRepo(runs);
|
|
@@ -138,7 +157,14 @@
|
|
|
138
157
|
}
|
|
139
158
|
sel.addEventListener('change', async () => {
|
|
140
159
|
const unsaved = FILES.filter(n => s.editors[n] && isDirty(s.editors[n]));
|
|
141
|
-
if (unsaved.length
|
|
160
|
+
if (unsaved.length) {
|
|
161
|
+
const ask = s.asking = ++s.asks;
|
|
162
|
+
const yes = await UI.confirmInline(picker, `Switch repository and discard the unsaved edits to ${unsaved.map(n => '.forge/' + n).join(', ')}?`, { danger: true });
|
|
163
|
+
if (s.asking !== ask) return; // a later change asked again, and that confirm decides
|
|
164
|
+
const late = s.pickerLate;
|
|
165
|
+
s.asking = 0; s.pickerLate = false;
|
|
166
|
+
if (!yes) { sel.value = s.gone ? '' : s.run; if (late) drawPicker(); return; }
|
|
167
|
+
}
|
|
142
168
|
pick(sel.value);
|
|
143
169
|
});
|
|
144
170
|
const lab = el('label', undefined, 'set-field'); lab.htmlFor = 'set-run';
|
|
@@ -151,11 +177,13 @@
|
|
|
151
177
|
}
|
|
152
178
|
// A new run: nothing of the old one stays, and confirms of the old body (now detached) no longer hold a redraw.
|
|
153
179
|
function resetRun() {
|
|
180
|
+
staleNote(null);
|
|
154
181
|
s.data = null; s.dataText = null; s.error = null; s.editors = {}; s.moved = {}; s.loading = s.run != null;
|
|
155
182
|
s.gen++; s.hold = 0; s.redraw = false;
|
|
156
183
|
}
|
|
157
184
|
function pick(key) {
|
|
158
185
|
s.run = key; resetRun();
|
|
186
|
+
s.asking = 0; s.pickerLate = false; // a switch confirm still open goes with the old picker
|
|
159
187
|
s.runsSig = null; drawPicker(); drawBody();
|
|
160
188
|
if (c.onPick) c.onPick(key);
|
|
161
189
|
load();
|
|
@@ -171,12 +199,12 @@
|
|
|
171
199
|
if (seq !== s.seq || run !== s.run) return; // a later pick or load answered
|
|
172
200
|
s.loading = false;
|
|
173
201
|
if (!res.ok) {
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
202
|
+
const failed = readFailure(res, s.data);
|
|
203
|
+
if (failed.keep) { staleNote(failed.message); return; } // the last good page, its editors and their unsaved text stay
|
|
176
204
|
if (s.hold > 0) return; // a confirm or a write is open: its strip must not be torn down
|
|
177
|
-
s.error =
|
|
205
|
+
s.error = failed.message; s.data = null; s.dataText = null; drawBody(); return;
|
|
178
206
|
}
|
|
179
|
-
s.error = null;
|
|
207
|
+
s.error = null; staleNote(null);
|
|
180
208
|
const files = object(object(res.data.repo).files);
|
|
181
209
|
for (const name of FILES) {
|
|
182
210
|
const next = dirtyState(s.editors[name], object(files[name]).content);
|
|
@@ -305,7 +333,9 @@
|
|
|
305
333
|
const top = el('div', undefined, 'set-file-head'); top.append(h, status);
|
|
306
334
|
art.append(top, el('p', blurb, 'muted set-file-blurb'));
|
|
307
335
|
if (file.note) art.append(el('p', text(file.note), 'warn set-file-note'));
|
|
308
|
-
|
|
336
|
+
const movedNote = el('p', 'The file changed on disk after you began editing; saving replaces it with your text.', 'warn set-file-note');
|
|
337
|
+
movedNote.hidden = !(isDirty(ed) && s.moved[name]); // sync() keeps it current as the text is typed
|
|
338
|
+
art.append(movedNote);
|
|
309
339
|
if (!s.writes) {
|
|
310
340
|
status.replaceChildren(el('i', undefined, 'lamp ' + (file.exists ? 'st-pass' : '')), el('span', file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
311
341
|
art.append(unreadable ? el('p', file.exists ? 'The content is not shown.' : 'Not set.', 'muted') : el('pre', text(file.content), 'set-pre'));
|
|
@@ -316,6 +346,7 @@
|
|
|
316
346
|
ta.dataset.editor = name; ta.rows = Math.max(3, Math.min(14, shown(ed).split('\n').length + 1));
|
|
317
347
|
ta.spellcheck = false; ta.setAttribute('autocapitalize', 'off'); ta.setAttribute('autocomplete', 'off');
|
|
318
348
|
ta.value = shown(ed);
|
|
349
|
+
let from = ed.base; // the file text this textarea was drawn from
|
|
319
350
|
if (unreadable && file.exists) { ta.disabled = true; ta.placeholder = 'The content is not shown, so it cannot be edited here.'; }
|
|
320
351
|
const problem = el('p', undefined, 'warn set-problem'); problem.hidden = true; problem.id = id + '-problem';
|
|
321
352
|
const result = el('div', undefined, 'set-result');
|
|
@@ -328,10 +359,15 @@
|
|
|
328
359
|
if (bad) { ta.setAttribute('aria-invalid', 'true'); ta.setAttribute('aria-describedby', problem.id); }
|
|
329
360
|
else { ta.removeAttribute('aria-invalid'); ta.removeAttribute('aria-describedby'); }
|
|
330
361
|
save.disabled = !dirtyNow || !!bad;
|
|
362
|
+
movedNote.hidden = !(dirtyNow && s.moved[name]);
|
|
331
363
|
status.replaceChildren(el('i', undefined, 'lamp ' + (dirtyNow ? 'set-amber' : file.exists ? 'st-pass' : '')),
|
|
332
364
|
el('span', dirtyNow ? 'unsaved edits' : file.exists ? (unreadable ? 'present, not shown' : 'present') : 'absent'));
|
|
333
365
|
};
|
|
334
|
-
ta.addEventListener('input', () => {
|
|
366
|
+
ta.addEventListener('input', () => {
|
|
367
|
+
const next = typed(s.editors[name], ta.value, from);
|
|
368
|
+
s.editors[name] = next.editor; s.moved[name] = next.moved || (next.dirty && !!s.moved[name]);
|
|
369
|
+
sync();
|
|
370
|
+
});
|
|
335
371
|
remove.disabled = !file.exists;
|
|
336
372
|
save.addEventListener('click', async () => {
|
|
337
373
|
const cur = s.editors[name], bad = valueProblem(name, cur.draft);
|
|
@@ -342,7 +378,7 @@
|
|
|
342
378
|
if (!yes) { ta.readOnly = false; remove.disabled = !file.exists; release(); sync(); save.focus({ preventScroll: true }); return; }
|
|
343
379
|
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), cur.draft));
|
|
344
380
|
UI.outcome(result, res, `Saved ${target}.`);
|
|
345
|
-
if (res.ok && gen === s.gen) s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n');
|
|
381
|
+
if (res.ok && gen === s.gen) { s.editors[name] = editorOf(cur.draft.endsWith('\n') ? cur.draft : cur.draft + '\n'); s.moved[name] = false; from = s.editors[name].base; }
|
|
346
382
|
ta.readOnly = false; remove.disabled = !file.exists; release(); sync();
|
|
347
383
|
if (res.ok && gen === s.gen) await reload();
|
|
348
384
|
});
|
|
@@ -353,7 +389,7 @@
|
|
|
353
389
|
if (!yes) { ta.readOnly = false; release(); sync(); remove.disabled = !file.exists; remove.focus({ preventScroll: true }); return; }
|
|
354
390
|
const res = await UI.post('/api/config', configBody(s.run, repoKey(name), null, true));
|
|
355
391
|
UI.outcome(result, res, `Removed ${target}.`);
|
|
356
|
-
if (res.ok && gen === s.gen) s.editors[name] = editorOf('');
|
|
392
|
+
if (res.ok && gen === s.gen) { s.editors[name] = editorOf(''); s.moved[name] = false; from = ''; }
|
|
357
393
|
ta.readOnly = false; release();
|
|
358
394
|
if (res.ok && gen === s.gen) await reload(); else if (!res.ok) { remove.disabled = !file.exists; sync(); }
|
|
359
395
|
});
|
|
@@ -395,5 +431,5 @@
|
|
|
395
431
|
}
|
|
396
432
|
|
|
397
433
|
return { render, configBody, repoKey, targetPath, subsetOk, valueProblem, pickDefault, groupRuns, optionLabel,
|
|
398
|
-
editorOf, isDirty, dirtyState, edited, shown, FILES, VALUE_MAX };
|
|
434
|
+
editorOf, isDirty, dirtyState, edited, typed, shown, readFailure, FILES, VALUE_MAX };
|
|
399
435
|
});
|
|
@@ -99,6 +99,9 @@
|
|
|
99
99
|
.st-fail, .st-error, .st-conflict, .st-infra, .st-timeout, .st-blocked { --st: var(--lamp-red); --st-lit: var(--lit-red); }
|
|
100
100
|
[class^="st-"], [class*=" st-"] { --st-fill: var(--st); --st-ring: var(--lamp-edge); }
|
|
101
101
|
.st-accepted, .st-nochanges, .st-interrupted, .st-infra, .st-timeout, .st-blocked, .st-outdated { --st-fill: transparent; --st-ring: var(--st); }
|
|
102
|
+
/* verification lanes: flaky passed on a rerun (amber, hollow), known passed with only baseline failures (green, hollow) */
|
|
103
|
+
.st-flaky { --st: var(--lamp-amber); --st-lit: var(--lit-amber); --st-fill: transparent; --st-ring: var(--st); }
|
|
104
|
+
.st-known { --st: var(--lamp-green); --st-lit: var(--lit-green); --st-fill: transparent; --st-ring: var(--st); }
|
|
102
105
|
/* review severity is a shape (P0 spike, P1 diamond, P2 cone, P3 pin) on a ramp of its own, never a status lamp */
|
|
103
106
|
.sev-P0 { --sev: var(--sev-p0); --sev-shape: polygon(50% 0, 100% 100%, 0 100%); }
|
|
104
107
|
.sev-P1 { --sev: var(--sev-p1); --sev-shape: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
|
|
@@ -563,6 +566,51 @@ summary:hover { color: var(--ink-2); }
|
|
|
563
566
|
ul.plain { list-style: none; padding: 0; margin: 4px 0; display: grid; gap: 4px; font-size: var(--t-sm); }
|
|
564
567
|
ul.plain li.current { font-weight: 650; }
|
|
565
568
|
pre.cmd { max-height: none; }
|
|
569
|
+
/* the verification inspector (verifyview.js): live state, failing tests, lanes and their logs */
|
|
570
|
+
.vfy-num { font-variant-numeric: tabular-nums; }
|
|
571
|
+
.vfy-live { margin: var(--s-3) 0 0; padding: var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 0 0 0 1px var(--rule); }
|
|
572
|
+
.vfy-now { margin: 0; font-size: var(--t-sm); }
|
|
573
|
+
.vfy-step { font-weight: 600; }
|
|
574
|
+
.vfy-last { margin: var(--s-2) 0 0; font-size: var(--t-xs); color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
575
|
+
.vfy-idle { margin: var(--s-2) 0 0; padding: 2px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); font-size: var(--t-xs); width: fit-content; }
|
|
576
|
+
.vfy-group { margin: var(--s-3) 0 0; }
|
|
577
|
+
.vfy-group-head { display: flex; gap: var(--s-2); align-items: baseline; margin: 0 0 var(--s-1); font-size: var(--t-sm); }
|
|
578
|
+
.vfy-tests { gap: var(--s-2); }
|
|
579
|
+
.vfy-test { padding: var(--s-2) var(--s-3); border-radius: var(--r-key); background: var(--module-2); box-shadow: inset 3px 0 0 var(--lamp-red); }
|
|
580
|
+
.vfy-test.vfy-known { box-shadow: inset 3px 0 0 var(--lamp-off); }
|
|
581
|
+
.vfy-test-head { display: flex; flex-wrap: wrap; gap: var(--s-2); align-items: baseline; }
|
|
582
|
+
.vfy-test-name { overflow-wrap: anywhere; font-size: var(--t-xs); }
|
|
583
|
+
.vfy-badge { padding: 0 6px; border-radius: var(--r-small); font-size: var(--t-xs); font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
|
|
584
|
+
.vfy-badge-new { background: var(--lamp-red); color: var(--plate-ink); }
|
|
585
|
+
.vfy-badge-known { box-shadow: inset 0 0 0 1px var(--rule-2); color: var(--muted); }
|
|
586
|
+
.vfy-message { margin: var(--s-1) 0 0; font-size: var(--t-sm); overflow-wrap: anywhere; }
|
|
587
|
+
.vfy-excerpt summary { cursor: pointer; font-size: var(--t-xs); color: var(--muted); }
|
|
588
|
+
/* a link-like button: none of the key's plate type, face or press */
|
|
589
|
+
.vfy-link { display: inline; min-height: 0; padding: 0; border: 0; background: none; box-shadow: none; color: var(--ink); text-decoration: underline; text-underline-offset: 2px;
|
|
590
|
+
font-family: var(--font-ui); font-weight: 400; font-size: var(--t-xs); letter-spacing: normal; text-transform: none; line-height: inherit; white-space: normal; text-align: left; }
|
|
591
|
+
.vfy-link.mono { font-family: var(--mono); }
|
|
592
|
+
.vfy-link:hover:not(:disabled) { background: none; color: var(--ink-2); }
|
|
593
|
+
.vfy-section-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); margin: 0 0 var(--s-2); }
|
|
594
|
+
.vfy-section-head h3 { margin: 0; }
|
|
595
|
+
.vfy-nowrap { white-space: nowrap; }
|
|
596
|
+
.vfy-table-wrap { overflow-x: auto; }
|
|
597
|
+
.vfy-table { width: 100%; border-collapse: collapse; font-size: var(--t-sm); }
|
|
598
|
+
.vfy-table th { text-align: left; font-weight: 600; color: var(--muted); font-size: var(--t-xs); padding: 0 var(--s-2) var(--s-1) 0; }
|
|
599
|
+
.vfy-table td { padding: var(--s-1) var(--s-2) var(--s-1) 0; border-top: 1px solid var(--rule); vertical-align: top; }
|
|
600
|
+
.vfy-table tr.vfy-followed td { background: var(--module-2); }
|
|
601
|
+
.vfy-lane .vfy-link { font-size: var(--t-sm); }
|
|
602
|
+
.vfy-label { margin-left: var(--s-1); font-size: var(--t-xs); }
|
|
603
|
+
.vfy-lane-now { display: grid; gap: 2px; margin-top: 2px; max-width: 28ch; }
|
|
604
|
+
.vfy-lane-now .vfy-last { margin: 0; }
|
|
605
|
+
.vfy-log { display: grid; padding: var(--s-2) 0; font-family: var(--mono); font-size: var(--t-xs); line-height: 1.5; white-space: pre; overflow: auto; }
|
|
606
|
+
.vfy-line { display: flex; gap: var(--s-3); padding: 0 var(--s-3); }
|
|
607
|
+
.vfy-line.vfy-hl { background: var(--warn-wash); color: var(--warn-ink); }
|
|
608
|
+
.vfy-ln { flex: none; min-width: 5ch; text-align: right; color: var(--glass-dim); font-variant-numeric: tabular-nums; user-select: none; }
|
|
609
|
+
.vfy-line.vfy-hl .vfy-ln { color: inherit; }
|
|
610
|
+
.vfy-lt { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
611
|
+
.vfy-note { font-size: var(--t-xs); margin: var(--s-1) 0 0; }
|
|
612
|
+
.vfy-summary, .vfy-endstep, .vfy-mutated { margin: var(--s-3) 0 0; font-size: var(--t-sm); }
|
|
613
|
+
.vfy-lanes + .fields, .vfy-tail + .fields { margin-top: var(--s-4); }
|
|
566
614
|
.phase-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin: 6px 0; background: var(--module-2); }
|
|
567
615
|
.phase { min-width: 2px; }
|
|
568
616
|
.phase-legend { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: var(--t-xs); color: var(--muted); }
|
|
@@ -624,6 +672,17 @@ p.ctl-why::before { content: ''; width: 7px; height: 7px; border-radius: 50%; ba
|
|
|
624
672
|
.ctl-pin-now { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
|
625
673
|
p.ctl-stop { display: flex; align-items: center; gap: 7px; margin: 0; padding: 4px var(--s-2); border-radius: var(--r-small); background: var(--warn-wash); color: var(--warn-ink); font-weight: 600; }
|
|
626
674
|
.ctl-log { margin-top: var(--s-1); }
|
|
675
|
+
/* the run's keys (drain, resume, stop), its drained or stopping state, pauses and a queued retry's answer */
|
|
676
|
+
.ctl-keys { display: flex; flex-wrap: wrap; gap: var(--s-2); }
|
|
677
|
+
.ctl-state { font-variant-numeric: tabular-nums; }
|
|
678
|
+
.ctl-stopping, .ctl-drained { background: var(--warn-wash); color: var(--warn-ink); border-color: transparent; }
|
|
679
|
+
.ctl-pauses { display: grid; gap: var(--s-1); }
|
|
680
|
+
.ctl-pause-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
|
|
681
|
+
.ctl-pause { display: flex; align-items: baseline; gap: 7px; }
|
|
682
|
+
.ctl-pause-text { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
683
|
+
.ctl-exhausted .ctl-pause-text { color: var(--warn-ink); font-weight: 600; }
|
|
684
|
+
p.ctl-request { display: flex; align-items: center; gap: 7px; margin: 0; font-size: var(--t-sm); }
|
|
685
|
+
p.ctl-request-refused { color: var(--warn-ink); font-weight: 600; }
|
|
627
686
|
.ctl-changes { list-style: none; margin: 4px 0 0; padding: 0; display: grid; gap: 4px; font-size: var(--t-xs); }
|
|
628
687
|
.ctl-changes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; }
|
|
629
688
|
.ctl-what { color: var(--ink); font-weight: 600; }
|
|
@@ -702,6 +761,7 @@ th:hover .th-sort, .th-sort.on { color: var(--ink); }
|
|
|
702
761
|
.gt-item.gt-sel .gt-box { stroke: var(--route); stroke-width: 2.5; }
|
|
703
762
|
.gt-item.gt-crit .gt-box { stroke-width: 2.4; }
|
|
704
763
|
.gt-ghost .gt-box { fill-opacity: .04; stroke-dasharray: 4 3; }
|
|
764
|
+
.gt-item.gt-approx .gt-box { stroke-dasharray: 2 2; } /* a verification run placed from durations, not from its own times */
|
|
705
765
|
.gt-future { opacity: .3; }
|
|
706
766
|
.gt-seg { fill: var(--st, var(--lamp-off)); }
|
|
707
767
|
.gt-seg.gt-dwarf { fill-opacity: .55; }
|