@giamme/forge 0.8.0 → 0.9.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 +24 -0
- package/README.md +106 -1766
- package/SKILL.md +3 -2
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +5 -0
- package/package.json +3 -2
- package/references/decompose.md +1 -1
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/harvest.py +1 -1
- package/scripts/forge_dataset/importer.py +1 -1
- package/scripts/forge_fractal/__init__.py +2 -1
- package/ui/README.md +80 -20
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +99 -17
- package/ui/forge_ui/export.py +16 -6
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +95 -7
- package/ui/forge_ui/server.py +10 -1
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +279 -124
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +25 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +68 -32
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +924 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -15,13 +15,21 @@ window.ForgeUI = (() => {
|
|
|
15
15
|
mapFilter: Object.assign({ text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }, store.get('map-filter', {})),
|
|
16
16
|
showAll: false, mode: store.get('mode', 'plan'), dim: store.get('view', '3d'),
|
|
17
17
|
overlay: Object.assign({ height: 'none', color: 'status', critical: false, arcs: true, ghosts: true }, store.get('overlay', {})),
|
|
18
|
+
layers: Object.assign({ cables: true, beads: true, lamps: true, roof: true, labels: true, beacons: true, finish: true, districts: true, halos: true, route: true }, store.get('layers', {})),
|
|
18
19
|
replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
|
|
19
20
|
lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
|
|
20
21
|
models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
|
|
21
|
-
files: new Map(), filesPrefs: store.get('files', {}) }
|
|
22
|
+
files: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
|
|
23
|
+
findings: new Map() };
|
|
22
24
|
state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
|
|
23
25
|
if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
|
|
24
26
|
const $ = id => document.getElementById(id);
|
|
27
|
+
const Shell = window.ForgeShell, Table = window.ForgeTable;
|
|
28
|
+
const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
|
|
29
|
+
const LAMP = { live: 'running', failed: 'fail', done: 'merged', pending: 'pending' };
|
|
30
|
+
const STATE_ORDER = { live: 0, failed: 1, pending: 2, done: 3 };
|
|
31
|
+
// the view on <main> and on <body>: the shell's CSS lays out each view (stage views fill the window)
|
|
32
|
+
function setView(main, view) { main.dataset.view = view; document.body.dataset.view = view; }
|
|
25
33
|
const display = value => value === null || value === undefined || value === '' ? '—' :
|
|
26
34
|
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
|
27
35
|
const list = value => Array.isArray(value) ? value : [];
|
|
@@ -47,13 +55,16 @@ window.ForgeUI = (() => {
|
|
|
47
55
|
if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
|
|
48
56
|
return base;
|
|
49
57
|
}
|
|
50
|
-
// a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file}
|
|
58
|
+
// a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file},
|
|
59
|
+
// opened at a file, or at one finding (its id from /findings)
|
|
51
60
|
function diffHash(key, spec) {
|
|
52
|
-
const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.file ? '/file/' + e(spec.file) : '';
|
|
61
|
+
const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.finding ? '/finding/' + e(spec.finding) : spec.file ? '/file/' + e(spec.file) : '';
|
|
53
62
|
if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
|
|
54
63
|
if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
|
|
55
64
|
return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
|
|
56
65
|
}
|
|
66
|
+
// an execution's conversation, in the stage's place like a diff
|
|
67
|
+
const convoHash = (key, sel) => hashFor(key, sel) + '/conversation';
|
|
57
68
|
function cloudHash(sel, focus) {
|
|
58
69
|
if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
|
|
59
70
|
return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
|
|
@@ -66,9 +77,15 @@ window.ForgeUI = (() => {
|
|
|
66
77
|
if (!m) return { key: null, sel: null };
|
|
67
78
|
const d = decodeURIComponent, rest = m[2] || '';
|
|
68
79
|
let sel = null, r;
|
|
69
|
-
if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current',
|
|
80
|
+
if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/(?!file\/|finding\/)([^/]+))?(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current', finding: r[3] ? d(r[3]) : null, file: r[4] ? d(r[4]) : null } };
|
|
70
81
|
if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'fractal', task: d(r[1]), node: d(r[2]), file: r[3] ? d(r[3]) : null } };
|
|
71
|
-
if ((r = rest.match(/^\/final-review\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'final', name: d(r[1]) }, diff: { kind: 'final', name: d(r[1]),
|
|
82
|
+
if ((r = rest.match(/^\/final-review\/([^/]+)\/diff(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'final', name: d(r[1]) }, diff: { kind: 'final', name: d(r[1]), finding: r[2] ? d(r[2]) : null, file: r[3] ? d(r[3]) : null } };
|
|
83
|
+
if ((r = rest.match(/^\/(?:tasks\/([^/]+)\/)?executions\/([^/]+)\/conversation\/?$/))) {
|
|
84
|
+
const s = r[1] ? { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) } : { kind: 'execution', task: null, exec: d(r[2]) };
|
|
85
|
+
const run = s.task && state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === s.task);
|
|
86
|
+
if (t && list(t.shadow).some(x => x.execution === s.exec)) s.shadow = true;
|
|
87
|
+
return { key: d(m[1]), sel: s, convo: { task: s.task, exec: s.exec } };
|
|
88
|
+
}
|
|
72
89
|
if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
|
|
73
90
|
if (sel && sel.kind === 'execution' && sel.task) {
|
|
74
91
|
const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
|
|
@@ -111,16 +128,6 @@ window.ForgeUI = (() => {
|
|
|
111
128
|
parent.append(node);
|
|
112
129
|
return node;
|
|
113
130
|
}
|
|
114
|
-
function fields(parent, entries) {
|
|
115
|
-
const dl = el('dl', undefined, 'fields');
|
|
116
|
-
for (const [label, value, mono] of entries) {
|
|
117
|
-
dl.append(el('dt', label));
|
|
118
|
-
const dd = el('dd', undefined, mono ? 'mono' : '');
|
|
119
|
-
dd.append(value instanceof Node ? value : document.createTextNode(display(value)));
|
|
120
|
-
dl.append(dd);
|
|
121
|
-
}
|
|
122
|
-
parent.append(dl);
|
|
123
|
-
}
|
|
124
131
|
function details(parent, title, value, key) {
|
|
125
132
|
const node = el('details');
|
|
126
133
|
node.dataset.keep = key;
|
|
@@ -193,11 +200,15 @@ window.ForgeUI = (() => {
|
|
|
193
200
|
banner.hidden = !state.errors.size;
|
|
194
201
|
}
|
|
195
202
|
function filteredRuns() {
|
|
196
|
-
|
|
203
|
+
const runs = state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())), byTime = (a, b) => (b.updated || 0) - (a.updated || 0);
|
|
204
|
+
if (state.runSort === 'state') return runs.sort((a, b) => STATE_ORDER[RM.runState(a)] - STATE_ORDER[RM.runState(b)] || byTime(a, b));
|
|
205
|
+
if (state.runSort === 'repo') return runs.sort((a, b) => RM.basename(a.repo).localeCompare(RM.basename(b.repo)) || byTime(a, b));
|
|
206
|
+
return runs.sort(byTime);
|
|
197
207
|
}
|
|
198
208
|
function renderList() {
|
|
199
209
|
const runs = filteredRuns();
|
|
200
|
-
$('run-count').textContent = `${runs.length}
|
|
210
|
+
$('run-count').textContent = `${runs.length}/${state.runs.length}`;
|
|
211
|
+
if ($('run-sort').value !== state.runSort) $('run-sort').value = state.runSort;
|
|
201
212
|
if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
|
|
202
213
|
preserve($('run-list'), () => {
|
|
203
214
|
if (!runs.length) {
|
|
@@ -205,7 +216,7 @@ window.ForgeUI = (() => {
|
|
|
205
216
|
empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
|
|
206
217
|
if (!state.runs.length) {
|
|
207
218
|
empty.append(el('p', 'Sources: explicit --plan directories, --root directories (up to two levels deep), and the forge run registry at $XDG_CONFIG_HOME/forge/runs/ (default ~/.config/forge/runs/). Every forge run registers itself there.'));
|
|
208
|
-
empty.append(el('p', '
|
|
219
|
+
empty.append(el('p', 'Open runs kept somewhere else:'), el('pre', 'forge ui open <run-dir>', 'cmd'));
|
|
209
220
|
}
|
|
210
221
|
$('run-list').append(empty);
|
|
211
222
|
}
|
|
@@ -213,15 +224,18 @@ window.ForgeUI = (() => {
|
|
|
213
224
|
const item = link(undefined, run.key);
|
|
214
225
|
item.className = 'run-item';
|
|
215
226
|
item.dataset.run = run.key;
|
|
227
|
+
const st = RM.runState(run), lamp = el('i', undefined, 'lamp run-lamp st-' + LAMP[st]);
|
|
228
|
+
lamp.title = { live: 'running', failed: 'has failures', done: 'all done', pending: 'not started' }[st];
|
|
216
229
|
const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
|
|
217
230
|
goal.title = run.goal || 'Untitled run';
|
|
218
231
|
const solo = run.kind === 'solo';
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (run.updated != null) updated.dataset.epoch = run.updated;
|
|
232
|
+
const sub = el('span', undefined, 'run-sub');
|
|
233
|
+
const updated = el('span', relative(run.updated).replace(/^updated /, ''), 'updated');
|
|
234
|
+
if (run.updated != null) { updated.dataset.epoch = run.updated; updated.dataset.short = ''; }
|
|
223
235
|
updated.title = time(run.updated);
|
|
224
|
-
|
|
236
|
+
sub.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), updated);
|
|
237
|
+
item.append(lamp, goal, sub);
|
|
238
|
+
if (solo) item.append(el('span', 'solo', 'tag'));
|
|
225
239
|
const counts = el('div', undefined, 'chips');
|
|
226
240
|
for (const [status, count] of Object.entries(run.counts || {})) {
|
|
227
241
|
const badge = chip(status);
|
|
@@ -261,6 +275,12 @@ window.ForgeUI = (() => {
|
|
|
261
275
|
onOpenRun: key => { location.hash = hashFor(key, null); },
|
|
262
276
|
onBack: () => { location.hash = '#/'; },
|
|
263
277
|
onMode: mode => setMode(mode),
|
|
278
|
+
onLayers: L => { state.layers = L; store.set('layers', L); syncLayerKeys(); }, // the stage's I key
|
|
279
|
+
// a finding's mark: the diff that review read, opened at that finding
|
|
280
|
+
onFinding: d => {
|
|
281
|
+
location.hash = d.final ? diffHash(state.key, { kind: 'final', name: d.final, finding: d.finding.id })
|
|
282
|
+
: diffHash(state.key, { kind: 'task', task: d.task, source: d.review && d.review.execution || 'current', finding: d.finding.id });
|
|
283
|
+
},
|
|
264
284
|
cloud: {
|
|
265
285
|
onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
|
|
266
286
|
onFocus: pid => { location.hash = cloudHash(null, pid); },
|
|
@@ -272,6 +292,7 @@ window.ForgeUI = (() => {
|
|
|
272
292
|
})).then(stage => {
|
|
273
293
|
state.stage = stage; state.stageLoading = false;
|
|
274
294
|
stage.setOverlay(state.overlay);
|
|
295
|
+
stage.setLayers(state.layers);
|
|
275
296
|
stage.setReplay(state.replay);
|
|
276
297
|
renderMain();
|
|
277
298
|
}, err => {
|
|
@@ -351,20 +372,43 @@ window.ForgeUI = (() => {
|
|
|
351
372
|
parent.append(wrap);
|
|
352
373
|
return A;
|
|
353
374
|
}
|
|
375
|
+
// No run at all yet (a fresh install, or nothing registered): say how to get one, instead of an empty
|
|
376
|
+
// plate under a filter bar with nothing to filter. The first run that registers replaces it with the map.
|
|
377
|
+
function renderFirstRun() {
|
|
378
|
+
const main = $('main');
|
|
379
|
+
if (main.dataset.view === 'first-run') return;
|
|
380
|
+
setView(main, 'first-run'); delete main.dataset.run; // the shell lays out each view by <body data-view>
|
|
381
|
+
main.replaceChildren();
|
|
382
|
+
const v = section(main, undefined, 'first-run'); v.className = 'first-run';
|
|
383
|
+
v.append(el('h1', 'No forge runs yet'),
|
|
384
|
+
el('p', 'forge-ui shows what forge records while it works: every solo run, plan and run registers itself here when it starts, with its tasks, diffs, reviews and verdicts.'),
|
|
385
|
+
el('h2', 'Start a run from your agent harness'),
|
|
386
|
+
el('pre', '/forge "<goal>" --dwarf <model>', 'cmd'),
|
|
387
|
+
el('p', 'Inside any agent CLI where forge is installed (forge install links it into each one it finds). The run shows up here as soon as it starts.', 'muted'),
|
|
388
|
+
el('h2', 'Open runs kept somewhere else'),
|
|
389
|
+
el('pre', 'forge ui open <run-dir>', 'cmd'),
|
|
390
|
+
el('p', 'A run dir is a plan or solo directory forge wrote. Runs register under $XDG_CONFIG_HOME/forge/runs/ (default ~/.config/forge/runs/); FORGE_RUN_REGISTRY=off turns that off.', 'muted'));
|
|
391
|
+
}
|
|
354
392
|
function renderMap({ rebuildBar = true } = {}) {
|
|
355
393
|
const main = $('main');
|
|
394
|
+
if (state.listLoaded && !state.runs.length && !SNAP) { renderFirstRun(); return; }
|
|
356
395
|
if (main.dataset.view !== 'map') {
|
|
357
|
-
main
|
|
396
|
+
setView(main, 'map'); delete main.dataset.run;
|
|
358
397
|
main.replaceChildren();
|
|
359
398
|
const v = section(main, undefined, 'map-view'); v.className = 'map-view';
|
|
399
|
+
const box = el('div', undefined, 'module map-head-module'); box.dataset.overlay = '';
|
|
360
400
|
const head = el('div', undefined, 'map-head');
|
|
361
|
-
const toCloud = el('a',
|
|
362
|
-
head.append(el('h1', 'All runs'), el('span', '', '
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
401
|
+
const toCloud = el('a', undefined, 'button push'); toCloud.href = '#/cloud'; toCloud.append(icon('cloud'), el('span', 'Run cloud'));
|
|
402
|
+
head.append(el('h1', 'All runs', 'module-title'), el('span', '', 'map-count'), viewToggle(), toCloud);
|
|
403
|
+
const bar = el('div'); bar.id = 'map-filters';
|
|
404
|
+
// on a phone the filters fold behind a key, so the map is the first thing on the screen
|
|
405
|
+
const fk = el('button', undefined, 'filters-key'); fk.type = 'button'; fk.setAttribute('aria-controls', 'map-filters'); fk.setAttribute('aria-expanded', 'false');
|
|
406
|
+
fk.append(icon('filter'), el('span', 'Filters'));
|
|
407
|
+
fk.addEventListener('click', () => { const open = box.dataset.filters !== 'open'; box.dataset.filters = open ? 'open' : 'closed'; fk.setAttribute('aria-expanded', String(open)); });
|
|
408
|
+
head.append(fk);
|
|
409
|
+
box.append(head, bar); v.append(box);
|
|
366
410
|
const slot = el('div', undefined, 'stage-slot map'); slot.id = 'stage-slot'; v.append(slot);
|
|
367
|
-
const cards = el('div'); cards.id = 'map-cards'; v.append(cards);
|
|
411
|
+
const cards = el('div', undefined, 'map-cards-host'); cards.id = 'map-cards'; v.append(cards);
|
|
368
412
|
rebuildBar = true;
|
|
369
413
|
}
|
|
370
414
|
if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
|
|
@@ -378,7 +422,7 @@ window.ForgeUI = (() => {
|
|
|
378
422
|
} else {
|
|
379
423
|
preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
|
|
380
424
|
}
|
|
381
|
-
const counter = $('map-view').querySelector('.map-head .
|
|
425
|
+
const counter = $('map-view').querySelector('.map-head .map-count');
|
|
382
426
|
if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
|
|
383
427
|
}
|
|
384
428
|
|
|
@@ -386,7 +430,7 @@ window.ForgeUI = (() => {
|
|
|
386
430
|
function renderCloud() {
|
|
387
431
|
const main = $('main');
|
|
388
432
|
if (main.dataset.view !== 'cloud' || !state.cloudUI) {
|
|
389
|
-
main
|
|
433
|
+
setView(main, 'cloud'); delete main.dataset.run;
|
|
390
434
|
main.replaceChildren();
|
|
391
435
|
const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
|
|
392
436
|
const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
|
|
@@ -412,18 +456,19 @@ window.ForgeUI = (() => {
|
|
|
412
456
|
function renderModels() {
|
|
413
457
|
const main = $('main');
|
|
414
458
|
if (main.dataset.view !== 'models') {
|
|
415
|
-
main
|
|
459
|
+
setView(main, 'models'); delete main.dataset.run;
|
|
416
460
|
main.replaceChildren();
|
|
417
461
|
const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
|
|
418
462
|
}
|
|
419
463
|
const host = $('models-view');
|
|
420
464
|
if (!state.modelsData) {
|
|
421
|
-
host.replaceChildren(el('h1', 'Models'), el('p', state.modelsError ? `Could not load the model results (${state.modelsError}).` : 'Reading every run\'s attempts and reviews…', state.modelsError ? 'warn' : 'muted'));
|
|
465
|
+
host.replaceChildren(el('h1', 'Models', 'module-title'), el('p', state.modelsError ? `Could not load the model results (${state.modelsError}).` : 'Reading every run\'s attempts and reviews…', state.modelsError ? 'warn' : 'muted'));
|
|
422
466
|
return;
|
|
423
467
|
}
|
|
424
468
|
window.ForgeLeaderboard.render(host, state.modelsData, {
|
|
425
469
|
runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
|
|
426
470
|
savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
|
|
471
|
+
tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
|
|
427
472
|
modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
|
|
428
473
|
});
|
|
429
474
|
}
|
|
@@ -449,6 +494,7 @@ window.ForgeUI = (() => {
|
|
|
449
494
|
function syncToggles() {
|
|
450
495
|
for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
|
|
451
496
|
for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
|
|
497
|
+
syncLayerKeys();
|
|
452
498
|
}
|
|
453
499
|
function setMode(mode) {
|
|
454
500
|
if (mode === state.mode) return;
|
|
@@ -457,15 +503,20 @@ window.ForgeUI = (() => {
|
|
|
457
503
|
syncToggles();
|
|
458
504
|
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
459
505
|
}
|
|
460
|
-
|
|
506
|
+
// the header's last row: the push-button bank that sets what the stage shows, then the replay's time base
|
|
507
|
+
function toolbar(header) {
|
|
461
508
|
const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
|
|
462
509
|
const modes = el('div', undefined, 'seg'); modes.setAttribute('role', 'group'); modes.setAttribute('aria-label', 'Plan or execution');
|
|
463
|
-
for (const [m, label, title] of [['plan', '
|
|
510
|
+
for (const [m, label, title] of [['plan', 'Plan', 'Planned waves: the tasks in the waves the plan put them in'], ['exec', 'Execution', 'Attempts on wall-clock time, in the lanes they really ran in (e)'],
|
|
464
511
|
['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
|
|
465
512
|
const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
|
|
466
513
|
}
|
|
467
514
|
bar.append(modes, viewToggle());
|
|
468
|
-
const ov = el('div', undefined, 'overlays');
|
|
515
|
+
const ov = el('div', undefined, 'overlays popover'); ov.popover = 'auto'; ov.id = 'view-pop'; ov.setAttribute('aria-label', 'What the stage shows');
|
|
516
|
+
ov.append(el('span', 'View', 'plate'));
|
|
517
|
+
const viewBtn = el('button', undefined, 'view-btn'); viewBtn.type = 'button'; viewBtn.popoverTargetElement = ov; viewBtn.setAttribute('aria-haspopup', 'true');
|
|
518
|
+
viewBtn.append(icon('layers'), el('span', 'View'), icon('chevron-up')); viewBtn.title = 'Height, colour, layers and marks';
|
|
519
|
+
ov.addEventListener('toggle', e => { if (e.newState === 'open') { syncLayerKeys(); placePopover(viewBtn, ov); } });
|
|
469
520
|
const sel = (label, key, entries) => {
|
|
470
521
|
const l = el('label', undefined, 'filter'); l.append(el('span', label));
|
|
471
522
|
const s = el('select'); s.dataset.ov = key;
|
|
@@ -474,35 +525,83 @@ window.ForgeUI = (() => {
|
|
|
474
525
|
};
|
|
475
526
|
sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
|
|
476
527
|
sel('Colour', 'color', [['status', 'status'], ['model', 'model'], ['duration', 'duration'], ['tokens', 'tokens']]);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
528
|
+
// push buttons, one per part of the drawing: its lamp lights while that part is drawn
|
|
529
|
+
const bankOf = (title, rows, attr) => {
|
|
530
|
+
const g = el('div', undefined, 'layer-bank'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', title);
|
|
531
|
+
for (const [key, label, tip] of rows) {
|
|
532
|
+
const b = el('button', label, 'layer-key'); b.type = 'button'; b.dataset[attr] = key; b.title = tip; g.append(b);
|
|
533
|
+
}
|
|
534
|
+
ov.append(el('span', title, 'plate'), g);
|
|
535
|
+
return g;
|
|
536
|
+
};
|
|
537
|
+
bankOf('Layers', [['cables', 'Cables', 'The cables from each task to the tasks that wait on it'], ['beads', 'Flow', 'Beads flowing down the cables into running tasks'],
|
|
538
|
+
['lamps', 'Lamps', 'The lamps on each block: one per attempt'], ['roof', 'Roof text', 'Task id, title, models and finding counts on each roof'],
|
|
539
|
+
['labels', 'Readouts', 'The status line on the front of each block'], ['beacons', 'Findings', 'A mark over the block per review finding, shaped by its severity'],
|
|
540
|
+
['finish', 'Finish line', 'Integration, verification and the final review'], ['districts', 'Districts', 'The wave and repository names on the plate'],
|
|
541
|
+
['halos', 'Halos', 'The glow under running and chosen blocks'], ['route', 'Route', 'A chosen task keeps its route lit: what it waits on and what waits on it (I)']], 'layer');
|
|
542
|
+
bankOf('Marks', [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'],
|
|
543
|
+
['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']], 'ov');
|
|
544
|
+
ov.addEventListener('click', e => {
|
|
545
|
+
const b = e.target.closest('.layer-key'); if (!b) return;
|
|
546
|
+
if (b.dataset.layer) {
|
|
547
|
+
const k = b.dataset.layer; state.layers[k] = !state.layers[k]; store.set('layers', state.layers);
|
|
548
|
+
if (state.stage) state.stage.setLayers({ [k]: state.layers[k] });
|
|
549
|
+
} else {
|
|
550
|
+
const k = b.dataset.ov; state.overlay[k] = !state.overlay[k]; store.set('overlay', state.overlay);
|
|
551
|
+
if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
|
|
552
|
+
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
553
|
+
}
|
|
554
|
+
syncLayerKeys();
|
|
555
|
+
});
|
|
482
556
|
ov.addEventListener('input', e => {
|
|
483
557
|
const k = e.target.dataset.ov; if (!k) return;
|
|
484
|
-
state.overlay[k] = e.target.
|
|
558
|
+
state.overlay[k] = e.target.value;
|
|
485
559
|
store.set('overlay', state.overlay);
|
|
486
560
|
if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
|
|
487
561
|
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
488
562
|
});
|
|
489
|
-
bar.append(ov);
|
|
563
|
+
bar.append(viewBtn, ov);
|
|
490
564
|
const rp = el('div', undefined, 'replay'); rp.id = 'replay';
|
|
491
|
-
const play = el('button',
|
|
565
|
+
const play = el('button', undefined, 'icon-btn'); play.type = 'button'; play.dataset.act = 'play'; play.append(icon('play')); play.setAttribute('aria-label', 'Replay'); play.title = 'Replay the run';
|
|
492
566
|
const range = el('input'); range.type = 'range'; range.min = '0'; range.max = '1000'; range.value = '1000'; range.dataset.act = 'scrub'; range.setAttribute('aria-label', 'Replay time');
|
|
493
567
|
const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
|
|
494
568
|
for (const v of [10, 60, 600]) { const o = el('option', `×${v}`); o.value = String(v); if (state.speed === v) o.selected = true; speed.append(o); }
|
|
495
|
-
const live = el('button'
|
|
569
|
+
const live = el('button'); live.type = 'button'; live.dataset.act = 'live'; live.append(icon('live'), el('span', 'Live'));
|
|
496
570
|
const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
|
|
497
571
|
rp.append(play, range, speed, live, at);
|
|
498
|
-
rp.addEventListener('click', e => { const a = e.target.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
|
|
572
|
+
rp.addEventListener('click', e => { const a = e.target.closest('[data-act]')?.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
|
|
499
573
|
rp.addEventListener('input', e => {
|
|
500
574
|
const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
|
|
501
575
|
if (a === 'scrub') { stopPlay(); const r = RM.timeRange(run, now()); if (r) setReplay(r.start + (r.end - r.start) * Number(e.target.value) / 1000); }
|
|
502
576
|
if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
|
|
503
577
|
});
|
|
504
|
-
|
|
505
|
-
|
|
578
|
+
// the header's action slot: the run's record opens in the inspector, the export opens its dialog
|
|
579
|
+
const acts = el('div', undefined, 'run-actions');
|
|
580
|
+
const rec = el('button', undefined, 'details-btn'); rec.type = 'button'; rec.title = 'Settings, request, waves and verification output, in the inspector (2)';
|
|
581
|
+
rec.append(icon('panel-right'), el('span', 'Run details'));
|
|
582
|
+
rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
|
|
583
|
+
acts.append(rec);
|
|
584
|
+
if (!SNAP) {
|
|
585
|
+
const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
|
|
586
|
+
ex.append(icon('download'), el('span', 'Export…'));
|
|
587
|
+
ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
|
|
588
|
+
}
|
|
589
|
+
bar.append(rp, acts);
|
|
590
|
+
header.append(bar);
|
|
591
|
+
}
|
|
592
|
+
// the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
|
|
593
|
+
function syncLayerKeys() {
|
|
594
|
+
for (const b of document.querySelectorAll('.layer-key')) {
|
|
595
|
+
const on = b.dataset.layer ? !!state.layers[b.dataset.layer] : !!state.overlay[b.dataset.ov];
|
|
596
|
+
b.setAttribute('aria-pressed', String(on));
|
|
597
|
+
if (b.dataset.layer) b.disabled = !use3d();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// a popover beside the button that opened it: above when the button sits low on the screen
|
|
601
|
+
function placePopover(btn, pop) {
|
|
602
|
+
const r = btn.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight, below = window.innerHeight - r.bottom > h + 16;
|
|
603
|
+
pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.left))) + 'px';
|
|
604
|
+
pop.style.top = Math.round(below ? r.bottom + 6 : Math.max(8, r.top - h - 6)) + 'px';
|
|
506
605
|
}
|
|
507
606
|
function syncReplay(run) {
|
|
508
607
|
const rp = $('replay'); if (!rp) return;
|
|
@@ -511,8 +610,9 @@ window.ForgeUI = (() => {
|
|
|
511
610
|
if (!r) return;
|
|
512
611
|
const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
|
|
513
612
|
range.value = String(state.replay === null ? 1000 : Math.round((state.replay - r.start) / Math.max(1, r.end - r.start) * 1000));
|
|
514
|
-
at.textContent = state.replay === null ? '
|
|
515
|
-
rp.querySelector('[data-act=play]')
|
|
613
|
+
at.textContent = state.replay === null ? '' : `+${RM.fmtDur(state.replay - r.start)} · ${new Date(state.replay * 1000).toLocaleTimeString()}`; // live: the Live key's lamp says it
|
|
614
|
+
const playBtn = rp.querySelector('[data-act=play]');
|
|
615
|
+
if (playBtn.dataset.on !== String(!!state.playing)) { playBtn.dataset.on = String(!!state.playing); playBtn.replaceChildren(icon(state.playing ? 'pause' : 'play')); playBtn.setAttribute('aria-label', state.playing ? 'Pause' : 'Replay'); playBtn.title = state.playing ? 'Pause the replay' : 'Replay the run'; }
|
|
516
616
|
rp.querySelector('[data-act=live]').disabled = state.replay === null;
|
|
517
617
|
}
|
|
518
618
|
function setReplay(t) {
|
|
@@ -521,19 +621,25 @@ window.ForgeUI = (() => {
|
|
|
521
621
|
if (state.stage) state.stage.setReplay(state.replay);
|
|
522
622
|
const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
|
|
523
623
|
}
|
|
524
|
-
function stopPlay() { if (state.playing) {
|
|
624
|
+
function stopPlay() { if (state.playing) { cancelAnimationFrame(state.playing); state.playing = null; } }
|
|
625
|
+
// the replay runs on the display's frames: the stage moves every frame, the panels around it four times a second
|
|
525
626
|
function togglePlay() {
|
|
526
627
|
const run = state.cache.get(state.key)?.data; if (!run) return;
|
|
527
628
|
if (state.playing) { stopPlay(); syncReplay(run); return; }
|
|
528
629
|
const r = RM.timeRange(run, now()); if (!r) return;
|
|
529
630
|
if (state.replay === null || state.replay >= r.end) state.replay = r.start;
|
|
530
|
-
|
|
631
|
+
let last = performance.now(), painted = 0;
|
|
632
|
+
const step = t0 => {
|
|
531
633
|
const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
|
|
532
634
|
if (!r2) { stopPlay(); return; }
|
|
533
|
-
const t = Math.min(r2.end, state.replay + state.speed * .
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
635
|
+
const t = Math.min(r2.end, state.replay + state.speed * Math.min(.25, (t0 - last) / 1000)); last = t0;
|
|
636
|
+
state.replay = t;
|
|
637
|
+
if (state.stage && use3d()) state.stage.setReplay(t);
|
|
638
|
+
if (t0 - painted > 250 || t >= r2.end || !use3d()) { painted = t0; renderRunBody(run2); } else syncReplay(run2);
|
|
639
|
+
if (t >= r2.end) { state.playing = null; syncReplay(run2); return; }
|
|
640
|
+
state.playing = requestAnimationFrame(step);
|
|
641
|
+
};
|
|
642
|
+
state.playing = requestAnimationFrame(step);
|
|
537
643
|
syncReplay(run);
|
|
538
644
|
}
|
|
539
645
|
// A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
|
|
@@ -543,60 +649,74 @@ window.ForgeUI = (() => {
|
|
|
543
649
|
return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
|
|
544
650
|
}
|
|
545
651
|
function renderHeader(run) {
|
|
546
|
-
const head = $('run-head');
|
|
652
|
+
const head = $('run-head').querySelector('.run-head-body');
|
|
547
653
|
preserve(head, () => {
|
|
548
654
|
const solo = run.kind === 'solo';
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
['Shadow reviewers', list(run.settings?.shadow_qa).join(', '), true], ['QA threshold', run.settings?.qa_threshold], ['Max parallel', run.settings?.max_parallel],
|
|
566
|
-
['Final reviewer', run.settings?.final_qa, true], ['Timeout (seconds)', run.settings?.timeout], ['Verification', chip(run.verification?.status || run.verification_status)],
|
|
567
|
-
['Command', run.verification?.command, true]]);
|
|
568
|
-
details(more, 'Request', run.request, 'run-request');
|
|
569
|
-
if (!solo) details(more, 'Waves', list(run.waves).map((ids, i) => `${i + 1}: ${list(ids).join(', ')}`).join('\n') || '—', 'waves');
|
|
570
|
-
if (run.verification?.log_tail != null) details(more, 'Verification output', run.verification.log_tail, 'verification-output');
|
|
571
|
-
head.append(more);
|
|
655
|
+
// one line: the run's lamp, its goal, where it ran, and the describer
|
|
656
|
+
const row = el('div', undefined, 'run-title-row');
|
|
657
|
+
const lamp = el('i', undefined, 'lamp st-' + LAMP[RM.runState(run)]);
|
|
658
|
+
const title = el('h2', String(run.goal || 'Untitled run').split('\n')[0]); title.title = run.goal || '';
|
|
659
|
+
const where = el('span', undefined, 'run-where'); where.title = `${run.repo || ''}\n${run.run_id || ''}`;
|
|
660
|
+
where.append(el('span', RM.basename(run.repo), 'repo'), document.createTextNode(' '), el('span', run.run_id, 'mono'));
|
|
661
|
+
if (solo) where.append(document.createTextNode(' · solo run'));
|
|
662
|
+
// the describer: each state's count, the wall time so far, and how long since anything moved
|
|
663
|
+
const desc = el('span', undefined, 'describer');
|
|
664
|
+
for (const [s, n] of Object.entries(run.counts || {})) { const d = el('span', undefined, 'desc-count'); d.append(el('i', undefined, 'lamp st-' + String(s).toLowerCase()), document.createTextNode(` ${s} ${n}`)); desc.append(d); }
|
|
665
|
+
const r = RM.timeRange(run, now());
|
|
666
|
+
if (r) desc.append(el('span', `WALL ${RM.fmtDur(r.end - r.start)}`, 'dim'));
|
|
667
|
+
const upd = el('span', relative(run.updated).replace(/^updated /, ''), 'dim updated'); upd.title = 'Last activity ' + time(run.updated);
|
|
668
|
+
if (run.updated != null) { upd.dataset.epoch = run.updated; upd.dataset.short = ''; }
|
|
669
|
+
desc.append(upd);
|
|
670
|
+
row.append(lamp, title, where, desc); head.append(row);
|
|
572
671
|
if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
|
|
573
672
|
if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
|
|
574
673
|
scoped(head, run.key + ':');
|
|
575
674
|
});
|
|
576
675
|
}
|
|
676
|
+
const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
677
|
+
const DIFF_RANK = { low: 0, medium: 1, high: 2 };
|
|
678
|
+
function taskColumns(run) {
|
|
679
|
+
const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
|
|
680
|
+
const busy = task => RM.taskTimes(task, null, now()).busy || null;
|
|
681
|
+
return [
|
|
682
|
+
{ id: 'id', label: 'ID', mono: true, value: t => t.id, cell: t => link(t.id, run.key, { kind: 'task', id: t.id }) },
|
|
683
|
+
{ id: 'title', label: 'Title', value: t => t.title },
|
|
684
|
+
{ id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
|
|
685
|
+
{ id: 'wave', label: 'Wave', value: t => Number.isFinite(Number(t.wave)) && t.wave !== null && t.wave !== '' ? Number(t.wave) : null, cell: t => display(t.wave) },
|
|
686
|
+
{ id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
|
|
687
|
+
{ id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
|
|
688
|
+
{ id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
|
|
689
|
+
{ id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
|
|
690
|
+
{ id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
|
|
691
|
+
{ id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
|
|
692
|
+
value: t => t.findings && t.findings.worst ? SEV_RANK[t.findings.worst] * 1000 - (t.findings[t.findings.worst] || 0) : t.findings && t.findings.plausible ? 4000 : null,
|
|
693
|
+
cell: t => { const f = t.findings, v = sev(f); if (!f || !f.worst) return v; const n = el('span', v, 'sev-chip sev-' + f.worst); return n; } },
|
|
694
|
+
{ id: 'busy', label: 'Busy', value: busy, cell: t => RM.fmtDur(busy(t)) },
|
|
695
|
+
{ id: 'explored', label: '* Explored / propensity', hidden: true, title: 'Tasks whose model forge drew at random, with the pick\'s propensity',
|
|
696
|
+
value: t => t.explored ? 1 : null,
|
|
697
|
+
cell: t => { const picks = list(t.route).filter(r => r.explored); return t.explored ? '* ' + (picks.length ? picks.map(r => `${display(r.chosen)} p=${display(r.propensity)}`).join('; ') : 'p=—') : '—'; } },
|
|
698
|
+
{ id: 'deps', label: 'Deps', hidden: true, value: t => list(t.deps).length || null,
|
|
699
|
+
cell: t => { const deps = el('span', undefined, 'mono'); list(t.deps).forEach((id, i) => { if (i) deps.append(document.createTextNode(', ')); deps.append(link(id, run.key, { kind: 'task', id })); }); if (!deps.hasChildNodes()) deps.textContent = '—'; return deps; } },
|
|
700
|
+
{ id: 'branch', label: 'Branch', mono: true, hidden: true, value: t => t.branch },
|
|
701
|
+
];
|
|
702
|
+
}
|
|
577
703
|
function renderTable(run) {
|
|
578
|
-
const host = $('run-tasks');
|
|
704
|
+
const host = $('run-tasks'); if (!host) return;
|
|
705
|
+
const box = host.closest('.tasks-module'), count = box && box.querySelector('.module-count');
|
|
706
|
+
if (count) count.textContent = run.kind === 'solo' ? '' : String(list(run.tasks).length);
|
|
707
|
+
const tools = box && box.querySelector('.module-tools');
|
|
708
|
+
let slot = tools && tools.querySelector('.dt-tools');
|
|
709
|
+
if (tools && !slot) { slot = el('span', undefined, 'dt-tools'); tools.prepend(slot); }
|
|
579
710
|
preserve(host, () => {
|
|
580
|
-
if (run.kind === 'solo') return;
|
|
581
|
-
host.append(el('
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!deps.hasChildNodes()) deps.textContent = '—';
|
|
590
|
-
return [link(task.id, run.key, { kind: 'task', id: task.id }), task.title, task.difficulty, task.wave, chip(task.status),
|
|
591
|
-
task.dwarf, task.qa, task.attempt, chip(task.verdict), sev(task.findings), RM.fmtDur(RM.taskTimes(task, null, now()).busy || null), exploration, deps, task.branch];
|
|
592
|
-
});
|
|
593
|
-
const body = table(host, ['ID', 'Title', 'Difficulty', 'Wave', 'Status', 'Dwarf', 'QA', 'Attempt', 'Verdict', 'Findings', 'Busy', '* Explored / propensity', 'Deps', 'Branch'], rows, 'Tasks');
|
|
594
|
-
Array.from(body.children).forEach((row, i) => {
|
|
595
|
-
const id = run.tasks[i].id;
|
|
596
|
-
row.classList.toggle('selected', !!state.sel && (state.sel.id === id || state.sel.task === id));
|
|
597
|
-
row.addEventListener('click', event => {
|
|
598
|
-
if (!event.target.closest('a') && !window.getSelection()?.toString()) select({ kind: 'task', id });
|
|
599
|
-
});
|
|
711
|
+
if (run.kind === 'solo') { if (slot) slot.replaceChildren(); return; }
|
|
712
|
+
if (!list(run.tasks).length) { if (slot) slot.replaceChildren(); host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
|
|
713
|
+
Table.render(host, {
|
|
714
|
+
id: 'tasks', label: 'Tasks', columns: taskColumns(run), rows: list(run.tasks), tools: slot, scrollKey: 'Tasks',
|
|
715
|
+
prefs: state.tablePrefs.tasks, savePrefs: p => { state.tablePrefs = { ...state.tablePrefs, tasks: p }; store.set('tables', state.tablePrefs); },
|
|
716
|
+
rowClass: t => state.sel && (state.sel.id === t.id || state.sel.task === t.id) ? 'selected' : '',
|
|
717
|
+
onRow: (t, tr) => tr.addEventListener('click', event => {
|
|
718
|
+
if (!event.target.closest('a, button') && !window.getSelection()?.toString()) select({ kind: 'task', id: t.id });
|
|
719
|
+
}),
|
|
600
720
|
});
|
|
601
721
|
scoped(host, run.key + ':');
|
|
602
722
|
});
|
|
@@ -610,6 +730,7 @@ window.ForgeUI = (() => {
|
|
|
610
730
|
fetchJSON: path => fetchJSON(path).then(r => r.data),
|
|
611
731
|
rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
|
|
612
732
|
hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
|
|
733
|
+
convoHash: s => convoHash(run.key, s),
|
|
613
734
|
onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
|
|
614
735
|
});
|
|
615
736
|
scoped(host, run.key + ':');
|
|
@@ -654,6 +775,15 @@ window.ForgeUI = (() => {
|
|
|
654
775
|
diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
|
|
655
776
|
prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
|
|
656
777
|
}
|
|
778
|
+
// every review's findings of the run, for the stage's marks: read again when the run changes
|
|
779
|
+
function syncFindings(run) {
|
|
780
|
+
const key = run.key, c = state.findings.get(key) || {};
|
|
781
|
+
if (c.data && state.stage) state.stage.setFindings(c.data);
|
|
782
|
+
if (c.updated === run.updated || c.pending === run.updated) return;
|
|
783
|
+
c.pending = run.updated; state.findings.set(key, c);
|
|
784
|
+
fetchJSON(`/api/runs/${encodeURIComponent(key)}/findings`).then(r => { c.data = r.data; }, () => {})
|
|
785
|
+
.finally(() => { c.updated = c.pending; c.pending = null; if (state.key === key && state.stage && c.data) state.stage.setFindings(c.data); });
|
|
786
|
+
}
|
|
657
787
|
function loadFiles(run) {
|
|
658
788
|
const key = run.key, updated = run.updated, c = state.files.get(key) || {};
|
|
659
789
|
if (c.pending === updated) return;
|
|
@@ -661,14 +791,22 @@ window.ForgeUI = (() => {
|
|
|
661
791
|
fetchJSON(`/api/runs/${encodeURIComponent(key)}/files`).then(r => { c.data = r.data; c.error = null; c.at = Date.now(); }, e => { c.error = e.message; })
|
|
662
792
|
.finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
|
|
663
793
|
}
|
|
664
|
-
// the diff viewer takes the stage's place while it is open
|
|
794
|
+
// the diff viewer, or an execution's conversation, takes the stage's place while it is open
|
|
665
795
|
function renderDiff(run) {
|
|
666
796
|
const host = $('diff-view'), view = $('run-view'); if (!host || !view) return;
|
|
667
|
-
const
|
|
668
|
-
view.classList.toggle('diff-open',
|
|
669
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
797
|
+
const kind = state.convo && window.ForgeConvo ? 'convo' : state.diff && window.ForgeDiff ? 'diff' : null;
|
|
798
|
+
view.classList.toggle('diff-open', !!kind); host.hidden = !kind;
|
|
799
|
+
if (host.dataset.kind !== (kind || '')) { host.replaceChildren(); host.diffSig = ''; host.convoFrame = null; host.dataset.kind = kind || ''; }
|
|
800
|
+
if (!kind) return;
|
|
801
|
+
const rerender = () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); };
|
|
802
|
+
if (kind === 'convo') {
|
|
803
|
+
const sel = state.sel, task = sel.task ? list(run.tasks).find(t => t.id === sel.task) : run.kind === 'solo' ? list(run.tasks)[0] : null;
|
|
804
|
+
const brief = list(task && task.executions).find(e => e.id === state.convo.exec) || null;
|
|
805
|
+
window.ForgeConvo.render(host, { run, spec: { task: task ? task.id : null, exec: state.convo.exec }, brief, live: !SNAP,
|
|
806
|
+
fetchJSON: path => fetchJSON(path).then(r => r.data), rerender, diffHash: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data), rerender,
|
|
672
810
|
href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
|
|
673
811
|
}
|
|
674
812
|
function renderRunBody(run) {
|
|
@@ -676,7 +814,7 @@ window.ForgeUI = (() => {
|
|
|
676
814
|
syncToggles();
|
|
677
815
|
syncReplay(run);
|
|
678
816
|
placeStage($('stage-slot'));
|
|
679
|
-
if (use3d() && state.stage) state.stage.showRun(run, { mode: state.stageMode, sel: state.sel });
|
|
817
|
+
if (use3d() && state.stage) { state.stage.showRun(run, { mode: state.stageMode, sel: state.sel }); syncFindings(run); }
|
|
680
818
|
render2d(run);
|
|
681
819
|
renderStrip(run);
|
|
682
820
|
renderFiles(run);
|
|
@@ -687,24 +825,32 @@ window.ForgeUI = (() => {
|
|
|
687
825
|
if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
|
|
688
826
|
stopPlay(); state.replay = null;
|
|
689
827
|
if (state.stage) state.stage.setReplay(null);
|
|
690
|
-
main
|
|
828
|
+
setView(main, 'run'); main.dataset.run = run.key;
|
|
691
829
|
main.replaceChildren();
|
|
692
|
-
const head = el('section', undefined, 'run-header'); head.id = 'run-head'; main.append(head);
|
|
693
830
|
const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
|
|
694
|
-
|
|
831
|
+
const head = el('section', undefined, 'run-header'); head.id = 'run-head'; head.dataset.overlay = '';
|
|
832
|
+
head.append(el('div', undefined, 'run-head-body')); view.append(head);
|
|
833
|
+
toolbar(head);
|
|
834
|
+
// what floats below the header (the 2D views) starts under it, however many rows it wraps to
|
|
835
|
+
if (window.ResizeObserver) new ResizeObserver(() => document.body.style.setProperty('--head-h', head.offsetHeight + 'px')).observe(head);
|
|
695
836
|
const row = el('div', undefined, 'stage-row');
|
|
696
837
|
const col = el('div', undefined, 'stage-col');
|
|
697
838
|
const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
|
|
698
839
|
const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
|
|
699
840
|
const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
|
|
700
841
|
col.append(slot, g2, files);
|
|
701
|
-
const
|
|
702
|
-
|
|
842
|
+
const module = el('aside', undefined, 'module insp-module'); module.setAttribute('aria-label', 'Inspector'); module.dataset.overlay = '';
|
|
843
|
+
const insp = el('div', undefined, 'inspector'); insp.id = 'inspector'; insp.tabIndex = -1;
|
|
844
|
+
module.append(Shell.head('insp', 'Inspector'), insp, Shell.splitter('insp'));
|
|
845
|
+
row.append(col, module); view.append(row);
|
|
703
846
|
const strip = el('div', undefined, 'strip-wrap'); strip.id = 'strip'; view.append(strip);
|
|
704
|
-
const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; view.append(diff);
|
|
705
|
-
const
|
|
706
|
-
|
|
847
|
+
const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; diff.tabIndex = -1; view.append(diff);
|
|
848
|
+
const box = el('section', undefined, 'module tasks-module'); box.setAttribute('aria-label', 'Tasks'); box.dataset.overlay = '';
|
|
849
|
+
const tasks = el('div', undefined, 'run-tasks'); tasks.id = 'run-tasks';
|
|
850
|
+
box.append(Shell.head('tasks', 'Tasks', ''), tasks, Shell.splitter('tasks'));
|
|
851
|
+
view.append(box);
|
|
707
852
|
}
|
|
853
|
+
document.body.classList.toggle('no-tasks', run.kind === 'solo');
|
|
708
854
|
ensureStage();
|
|
709
855
|
renderHeader(run);
|
|
710
856
|
renderRunBody(run);
|
|
@@ -713,6 +859,7 @@ window.ForgeUI = (() => {
|
|
|
713
859
|
}
|
|
714
860
|
function renderMain() {
|
|
715
861
|
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
862
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
716
863
|
if (state.cloud) { renderCloud(); return; }
|
|
717
864
|
if (state.models) { renderModels(); return; }
|
|
718
865
|
if (!state.key) { renderMap(); return; }
|
|
@@ -765,7 +912,7 @@ window.ForgeUI = (() => {
|
|
|
765
912
|
return request('/api/runs', result => {
|
|
766
913
|
if (result.text === state.listText) return;
|
|
767
914
|
state.runs = list(result.data.runs);
|
|
768
|
-
state.listText = result.text;
|
|
915
|
+
state.listText = result.text; state.listLoaded = true;
|
|
769
916
|
if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
|
|
770
917
|
renderList();
|
|
771
918
|
if (state.cloud) renderCloud();
|
|
@@ -780,7 +927,7 @@ window.ForgeUI = (() => {
|
|
|
780
927
|
}, 'Could not refresh run', error => {
|
|
781
928
|
if (state.key !== key || state.cache.has(key)) return;
|
|
782
929
|
if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
|
|
783
|
-
$('main')
|
|
930
|
+
setView($('main'), 'waiting');
|
|
784
931
|
$('main').replaceChildren(el('p', error.status === 404 ?
|
|
785
932
|
'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
|
|
786
933
|
'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
|
|
@@ -795,9 +942,13 @@ window.ForgeUI = (() => {
|
|
|
795
942
|
}
|
|
796
943
|
const keyChanged = parsed.key !== state.key;
|
|
797
944
|
state.key = parsed.key;
|
|
945
|
+
const selWas = JSON.stringify(state.sel || null);
|
|
798
946
|
state.sel = parsed.sel;
|
|
799
|
-
|
|
947
|
+
// a selection is read in the inspector, so choosing one opens it
|
|
948
|
+
if (state.sel && JSON.stringify(state.sel) !== selWas && document.body.dataset.insp !== 'open') Shell.toggle('insp', true);
|
|
949
|
+
const diffWas = JSON.stringify(state.diff || null), convoWas = JSON.stringify(state.convo || null);
|
|
800
950
|
state.diff = parsed.diff || null;
|
|
951
|
+
state.convo = parsed.convo || null;
|
|
801
952
|
state.cloud = parsed.cloud || null;
|
|
802
953
|
state.models = !!parsed.models;
|
|
803
954
|
if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
|
|
@@ -805,6 +956,7 @@ window.ForgeUI = (() => {
|
|
|
805
956
|
history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
|
|
806
957
|
}
|
|
807
958
|
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
959
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
808
960
|
for (const a of document.querySelectorAll('.topbar .top-link')) {
|
|
809
961
|
const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
|
|
810
962
|
if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
|
@@ -818,11 +970,12 @@ window.ForgeUI = (() => {
|
|
|
818
970
|
if (cached) {
|
|
819
971
|
if (keyChanged) renderRun(cached.data);
|
|
820
972
|
else { renderRunBody(cached.data); renderTable(cached.data); }
|
|
821
|
-
} else { $('main')
|
|
973
|
+
} else { setView($('main'), 'loading'); $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
|
|
822
974
|
loadRun(state.key);
|
|
823
975
|
// a selection made on the stage keeps the keyboard there (← → walk the tasks); one made elsewhere moves it to the inspector
|
|
824
|
-
if (state.diff) {
|
|
825
|
-
const opened =
|
|
976
|
+
if (state.diff || state.convo) {
|
|
977
|
+
const opened = state.convo ? convoWas !== JSON.stringify(state.convo) :
|
|
978
|
+
diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
|
|
826
979
|
if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
|
|
827
980
|
} else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
|
|
828
981
|
}
|
|
@@ -840,7 +993,7 @@ window.ForgeUI = (() => {
|
|
|
840
993
|
loadList();
|
|
841
994
|
if (state.key) loadRun(state.key);
|
|
842
995
|
if (state.models) loadModels();
|
|
843
|
-
for (const node of document.querySelectorAll('[data-epoch]'))
|
|
996
|
+
for (const node of document.querySelectorAll('[data-epoch]')) { const t = relative(node.dataset.epoch); node.textContent = node.dataset.short !== undefined ? t.replace(/^updated /, '') : t; }
|
|
844
997
|
}
|
|
845
998
|
// Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
|
|
846
999
|
function schedule() {
|
|
@@ -911,8 +1064,10 @@ window.ForgeUI = (() => {
|
|
|
911
1064
|
if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
|
|
912
1065
|
if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
|
|
913
1066
|
$('filter').addEventListener('input', event => { state.mapFilter.text = event.target.value; filterChanged(true); });
|
|
1067
|
+
$('run-sort').addEventListener('change', event => { state.runSort = event.target.value; store.set('run-sort', state.runSort); renderList(); });
|
|
914
1068
|
$('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
|
|
915
1069
|
window.addEventListener('hashchange', navigate);
|
|
1070
|
+
if (Shell) Shell.onChange(() => { if (state.stage && !state.cloud) requestAnimationFrame(() => state.stage.reframe && state.stage.reframe()); });
|
|
916
1071
|
document.addEventListener('visibilitychange', refresh);
|
|
917
1072
|
window.addEventListener('resize', () => { const run = !state.cloud && state.cache.get(state.key)?.data; if (run && (!use3d() || !state.stage || state.stageMode === 'exec')) { render2d(run); renderStrip(run); } });
|
|
918
1073
|
loadList();
|