@giamme/forge 0.8.0 → 0.10.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 +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -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 +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- 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/findingsview.js +412 -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 +31 -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 +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- 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/settings.js +399 -0
- 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 +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/* forge-ui:
|
|
1
|
+
/* forge-ui: the page. It reads the API; a change to a run goes through the write helpers in uikit.js (see controls.js). All API values enter the DOM as text.
|
|
2
2
|
Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
|
|
3
3
|
run (#/runs/<key>) with its stage (3D, or the 2D graph and chart), the inspector beside it and the task table under it. The stage is one element that moves between views,
|
|
4
4
|
so the 3D scene (and its WebGL context) survives navigation and refreshes. */
|
|
5
|
-
window.
|
|
5
|
+
window.ForgeApp = (() => {
|
|
6
6
|
'use strict';
|
|
7
7
|
const RM = window.ForgeRunModel;
|
|
8
8
|
// an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
|
|
@@ -15,13 +15,22 @@ 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', {}),
|
|
21
|
+
settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
|
|
20
22
|
models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
|
|
21
|
-
files: new Map(), filesPrefs: store.get('files', {}) }
|
|
23
|
+
files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
|
|
24
|
+
findings: new Map() };
|
|
22
25
|
state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
|
|
23
26
|
if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
|
|
24
27
|
const $ = id => document.getElementById(id);
|
|
28
|
+
const Shell = window.ForgeShell, Table = window.ForgeTable;
|
|
29
|
+
const icon = name => window.ForgeIcons ? window.ForgeIcons.icon(name) : document.createTextNode('');
|
|
30
|
+
const LAMP = { live: 'running', failed: 'fail', done: 'merged', pending: 'pending' };
|
|
31
|
+
const STATE_ORDER = { live: 0, failed: 1, pending: 2, done: 3 };
|
|
32
|
+
// the view on <main> and on <body>: the shell's CSS lays out each view (stage views fill the window)
|
|
33
|
+
function setView(main, view) { main.dataset.view = view; document.body.dataset.view = view; }
|
|
25
34
|
const display = value => value === null || value === undefined || value === '' ? '—' :
|
|
26
35
|
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
|
27
36
|
const list = value => Array.isArray(value) ? value : [];
|
|
@@ -47,13 +56,16 @@ window.ForgeUI = (() => {
|
|
|
47
56
|
if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
|
|
48
57
|
return base;
|
|
49
58
|
}
|
|
50
|
-
// a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file}
|
|
59
|
+
// a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file},
|
|
60
|
+
// opened at a file, or at one finding (its id from /findings)
|
|
51
61
|
function diffHash(key, spec) {
|
|
52
|
-
const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.file ? '/file/' + e(spec.file) : '';
|
|
62
|
+
const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.finding ? '/finding/' + e(spec.finding) : spec.file ? '/file/' + e(spec.file) : '';
|
|
53
63
|
if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
|
|
54
64
|
if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
|
|
55
65
|
return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
|
|
56
66
|
}
|
|
67
|
+
// an execution's conversation, in the stage's place like a diff
|
|
68
|
+
const convoHash = (key, sel) => hashFor(key, sel) + '/conversation';
|
|
57
69
|
function cloudHash(sel, focus) {
|
|
58
70
|
if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
|
|
59
71
|
return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
|
|
@@ -62,13 +74,21 @@ window.ForgeUI = (() => {
|
|
|
62
74
|
const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
|
|
63
75
|
if (c) return { key: null, sel: null, cloud: { sel: c[1] === 'runs' ? decodeURIComponent(c[2]) : null, focus: c[1] === 'projects' ? decodeURIComponent(c[2]) : null, model: c[1] === 'models' ? decodeURIComponent(c[2]) : null } };
|
|
64
76
|
if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
|
|
77
|
+
if (/^#\/settings\/?$/.test(hash)) return { key: null, sel: null, settings: true };
|
|
65
78
|
const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
|
|
66
79
|
if (!m) return { key: null, sel: null };
|
|
67
80
|
const d = decodeURIComponent, rest = m[2] || '';
|
|
81
|
+
if (/^\/findings\/?$/.test(rest)) return { key: d(m[1]), sel: null, findings: true };
|
|
68
82
|
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',
|
|
83
|
+
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
84
|
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]),
|
|
85
|
+
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 } };
|
|
86
|
+
if ((r = rest.match(/^\/(?:tasks\/([^/]+)\/)?executions\/([^/]+)\/conversation\/?$/))) {
|
|
87
|
+
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]) };
|
|
88
|
+
const run = s.task && state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === s.task);
|
|
89
|
+
if (t && list(t.shadow).some(x => x.execution === s.exec)) s.shadow = true;
|
|
90
|
+
return { key: d(m[1]), sel: s, convo: { task: s.task, exec: s.exec } };
|
|
91
|
+
}
|
|
72
92
|
if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
|
|
73
93
|
if (sel && sel.kind === 'execution' && sel.task) {
|
|
74
94
|
const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
|
|
@@ -111,16 +131,6 @@ window.ForgeUI = (() => {
|
|
|
111
131
|
parent.append(node);
|
|
112
132
|
return node;
|
|
113
133
|
}
|
|
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
134
|
function details(parent, title, value, key) {
|
|
125
135
|
const node = el('details');
|
|
126
136
|
node.dataset.keep = key;
|
|
@@ -164,8 +174,10 @@ window.ForgeUI = (() => {
|
|
|
164
174
|
const active = parent.contains(document.activeElement) ? document.activeElement : null;
|
|
165
175
|
const focusHref = active?.getAttribute('href');
|
|
166
176
|
const own = [parent.scrollLeft, parent.scrollTop];
|
|
177
|
+
const held = window.ForgeControls ? window.ForgeControls.hold(parent) : null; // a control being edited rides through the rebuild
|
|
167
178
|
parent.replaceChildren();
|
|
168
179
|
build();
|
|
180
|
+
if (held) held.restore();
|
|
169
181
|
if (!restore) return;
|
|
170
182
|
for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
|
|
171
183
|
for (const node of parent.querySelectorAll('[data-scroll]')) {
|
|
@@ -193,11 +205,15 @@ window.ForgeUI = (() => {
|
|
|
193
205
|
banner.hidden = !state.errors.size;
|
|
194
206
|
}
|
|
195
207
|
function filteredRuns() {
|
|
196
|
-
|
|
208
|
+
const runs = state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())), byTime = (a, b) => (b.updated || 0) - (a.updated || 0);
|
|
209
|
+
if (state.runSort === 'state') return runs.sort((a, b) => STATE_ORDER[RM.runState(a)] - STATE_ORDER[RM.runState(b)] || byTime(a, b));
|
|
210
|
+
if (state.runSort === 'repo') return runs.sort((a, b) => RM.basename(a.repo).localeCompare(RM.basename(b.repo)) || byTime(a, b));
|
|
211
|
+
return runs.sort(byTime);
|
|
197
212
|
}
|
|
198
213
|
function renderList() {
|
|
199
214
|
const runs = filteredRuns();
|
|
200
|
-
$('run-count').textContent = `${runs.length}
|
|
215
|
+
$('run-count').textContent = `${runs.length}/${state.runs.length}`;
|
|
216
|
+
if ($('run-sort').value !== state.runSort) $('run-sort').value = state.runSort;
|
|
201
217
|
if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
|
|
202
218
|
preserve($('run-list'), () => {
|
|
203
219
|
if (!runs.length) {
|
|
@@ -205,7 +221,7 @@ window.ForgeUI = (() => {
|
|
|
205
221
|
empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
|
|
206
222
|
if (!state.runs.length) {
|
|
207
223
|
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', '
|
|
224
|
+
empty.append(el('p', 'Open runs kept somewhere else:'), el('pre', 'forge ui open <run-dir>', 'cmd'));
|
|
209
225
|
}
|
|
210
226
|
$('run-list').append(empty);
|
|
211
227
|
}
|
|
@@ -213,15 +229,18 @@ window.ForgeUI = (() => {
|
|
|
213
229
|
const item = link(undefined, run.key);
|
|
214
230
|
item.className = 'run-item';
|
|
215
231
|
item.dataset.run = run.key;
|
|
232
|
+
const st = RM.runState(run), lamp = el('i', undefined, 'lamp run-lamp st-' + LAMP[st]);
|
|
233
|
+
lamp.title = { live: 'running', failed: 'has failures', done: 'all done', pending: 'not started' }[st];
|
|
216
234
|
const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
|
|
217
235
|
goal.title = run.goal || 'Untitled run';
|
|
218
236
|
const solo = run.kind === 'solo';
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (run.updated != null) updated.dataset.epoch = run.updated;
|
|
237
|
+
const sub = el('span', undefined, 'run-sub');
|
|
238
|
+
const updated = el('span', relative(run.updated).replace(/^updated /, ''), 'updated');
|
|
239
|
+
if (run.updated != null) { updated.dataset.epoch = run.updated; updated.dataset.short = ''; }
|
|
223
240
|
updated.title = time(run.updated);
|
|
224
|
-
|
|
241
|
+
sub.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), updated);
|
|
242
|
+
item.append(lamp, goal, sub);
|
|
243
|
+
if (solo) item.append(el('span', 'solo', 'tag'));
|
|
225
244
|
const counts = el('div', undefined, 'chips');
|
|
226
245
|
for (const [status, count] of Object.entries(run.counts || {})) {
|
|
227
246
|
const badge = chip(status);
|
|
@@ -261,6 +280,12 @@ window.ForgeUI = (() => {
|
|
|
261
280
|
onOpenRun: key => { location.hash = hashFor(key, null); },
|
|
262
281
|
onBack: () => { location.hash = '#/'; },
|
|
263
282
|
onMode: mode => setMode(mode),
|
|
283
|
+
onLayers: L => { state.layers = L; store.set('layers', L); syncLayerKeys(); }, // the stage's I key
|
|
284
|
+
// a finding's mark: the diff that review read, opened at that finding
|
|
285
|
+
onFinding: d => {
|
|
286
|
+
location.hash = d.final ? diffHash(state.key, { kind: 'final', name: d.final, finding: d.finding.id })
|
|
287
|
+
: diffHash(state.key, { kind: 'task', task: d.task, source: d.review && d.review.execution || 'current', finding: d.finding.id });
|
|
288
|
+
},
|
|
264
289
|
cloud: {
|
|
265
290
|
onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
|
|
266
291
|
onFocus: pid => { location.hash = cloudHash(null, pid); },
|
|
@@ -272,6 +297,7 @@ window.ForgeUI = (() => {
|
|
|
272
297
|
})).then(stage => {
|
|
273
298
|
state.stage = stage; state.stageLoading = false;
|
|
274
299
|
stage.setOverlay(state.overlay);
|
|
300
|
+
stage.setLayers(state.layers);
|
|
275
301
|
stage.setReplay(state.replay);
|
|
276
302
|
renderMain();
|
|
277
303
|
}, err => {
|
|
@@ -351,20 +377,43 @@ window.ForgeUI = (() => {
|
|
|
351
377
|
parent.append(wrap);
|
|
352
378
|
return A;
|
|
353
379
|
}
|
|
380
|
+
// No run at all yet (a fresh install, or nothing registered): say how to get one, instead of an empty
|
|
381
|
+
// plate under a filter bar with nothing to filter. The first run that registers replaces it with the map.
|
|
382
|
+
function renderFirstRun() {
|
|
383
|
+
const main = $('main');
|
|
384
|
+
if (main.dataset.view === 'first-run') return;
|
|
385
|
+
setView(main, 'first-run'); delete main.dataset.run; // the shell lays out each view by <body data-view>
|
|
386
|
+
main.replaceChildren();
|
|
387
|
+
const v = section(main, undefined, 'first-run'); v.className = 'first-run';
|
|
388
|
+
v.append(el('h1', 'No forge runs yet'),
|
|
389
|
+
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.'),
|
|
390
|
+
el('h2', 'Start a run from your agent harness'),
|
|
391
|
+
el('pre', '/forge "<goal>" --dwarf <model>', 'cmd'),
|
|
392
|
+
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'),
|
|
393
|
+
el('h2', 'Open runs kept somewhere else'),
|
|
394
|
+
el('pre', 'forge ui open <run-dir>', 'cmd'),
|
|
395
|
+
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'));
|
|
396
|
+
}
|
|
354
397
|
function renderMap({ rebuildBar = true } = {}) {
|
|
355
398
|
const main = $('main');
|
|
399
|
+
if (state.listLoaded && !state.runs.length && !SNAP) { renderFirstRun(); return; }
|
|
356
400
|
if (main.dataset.view !== 'map') {
|
|
357
|
-
main
|
|
401
|
+
setView(main, 'map'); delete main.dataset.run;
|
|
358
402
|
main.replaceChildren();
|
|
359
403
|
const v = section(main, undefined, 'map-view'); v.className = 'map-view';
|
|
404
|
+
const box = el('div', undefined, 'module map-head-module'); box.dataset.overlay = '';
|
|
360
405
|
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
|
|
406
|
+
const toCloud = el('a', undefined, 'button push'); toCloud.href = '#/cloud'; toCloud.append(icon('cloud'), el('span', 'Run cloud'));
|
|
407
|
+
head.append(el('h1', 'All runs', 'module-title'), el('span', '', 'map-count'), viewToggle(), toCloud);
|
|
408
|
+
const bar = el('div'); bar.id = 'map-filters';
|
|
409
|
+
// on a phone the filters fold behind a key, so the map is the first thing on the screen
|
|
410
|
+
const fk = el('button', undefined, 'filters-key'); fk.type = 'button'; fk.setAttribute('aria-controls', 'map-filters'); fk.setAttribute('aria-expanded', 'false');
|
|
411
|
+
fk.append(icon('filter'), el('span', 'Filters'));
|
|
412
|
+
fk.addEventListener('click', () => { const open = box.dataset.filters !== 'open'; box.dataset.filters = open ? 'open' : 'closed'; fk.setAttribute('aria-expanded', String(open)); });
|
|
413
|
+
head.append(fk);
|
|
414
|
+
box.append(head, bar); v.append(box);
|
|
366
415
|
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);
|
|
416
|
+
const cards = el('div', undefined, 'map-cards-host'); cards.id = 'map-cards'; v.append(cards);
|
|
368
417
|
rebuildBar = true;
|
|
369
418
|
}
|
|
370
419
|
if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
|
|
@@ -378,7 +427,7 @@ window.ForgeUI = (() => {
|
|
|
378
427
|
} else {
|
|
379
428
|
preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
|
|
380
429
|
}
|
|
381
|
-
const counter = $('map-view').querySelector('.map-head .
|
|
430
|
+
const counter = $('map-view').querySelector('.map-head .map-count');
|
|
382
431
|
if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
|
|
383
432
|
}
|
|
384
433
|
|
|
@@ -386,7 +435,7 @@ window.ForgeUI = (() => {
|
|
|
386
435
|
function renderCloud() {
|
|
387
436
|
const main = $('main');
|
|
388
437
|
if (main.dataset.view !== 'cloud' || !state.cloudUI) {
|
|
389
|
-
main
|
|
438
|
+
setView(main, 'cloud'); delete main.dataset.run;
|
|
390
439
|
main.replaceChildren();
|
|
391
440
|
const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
|
|
392
441
|
const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
|
|
@@ -412,18 +461,19 @@ window.ForgeUI = (() => {
|
|
|
412
461
|
function renderModels() {
|
|
413
462
|
const main = $('main');
|
|
414
463
|
if (main.dataset.view !== 'models') {
|
|
415
|
-
main
|
|
464
|
+
setView(main, 'models'); delete main.dataset.run;
|
|
416
465
|
main.replaceChildren();
|
|
417
466
|
const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
|
|
418
467
|
}
|
|
419
468
|
const host = $('models-view');
|
|
420
469
|
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'));
|
|
470
|
+
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
471
|
return;
|
|
423
472
|
}
|
|
424
473
|
window.ForgeLeaderboard.render(host, state.modelsData, {
|
|
425
474
|
runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
|
|
426
475
|
savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
|
|
476
|
+
tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
|
|
427
477
|
modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
|
|
428
478
|
});
|
|
429
479
|
}
|
|
@@ -436,6 +486,41 @@ window.ForgeUI = (() => {
|
|
|
436
486
|
}, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
|
|
437
487
|
}
|
|
438
488
|
|
|
489
|
+
/* ---------- the settings (#/settings) ---------- */
|
|
490
|
+
function renderSettings() {
|
|
491
|
+
const main = $('main');
|
|
492
|
+
if (main.dataset.view === 'settings') { state.settingsView?.poll(); return; } // already drawn: its unsaved edits stay
|
|
493
|
+
setView(main, 'settings'); delete main.dataset.run;
|
|
494
|
+
main.replaceChildren();
|
|
495
|
+
const v = el('section', undefined, 'settings-view'); v.id = 'settings-view'; main.append(v);
|
|
496
|
+
state.settingsView = window.ForgeSettings.render(v, {
|
|
497
|
+
// a pick holds only while the current run is the one it was made from: the default is the current or most recent run
|
|
498
|
+
key: state.settingsRun && state.lastKey === state.settingsPickedAt ? state.settingsRun : state.lastKey,
|
|
499
|
+
runs: () => state.runs, onPick: key => { state.settingsRun = key; state.settingsPickedAt = state.lastKey; },
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* ---------- a run's open findings (#/runs/<key>/findings) ---------- */
|
|
504
|
+
function renderFindings() {
|
|
505
|
+
const main = $('main');
|
|
506
|
+
if (main.dataset.view === 'findings' && main.dataset.run === state.key) return; // already drawn: a toggle elsewhere must not reload it
|
|
507
|
+
setView(main, 'findings'); main.dataset.run = state.key;
|
|
508
|
+
main.replaceChildren();
|
|
509
|
+
const v = el('section', undefined, 'findings-view'); v.id = 'findings-view'; main.append(v);
|
|
510
|
+
window.ForgeFindings.render(v, {
|
|
511
|
+
key: state.key, run: state.cache.get(state.key)?.data, back: '#/runs/' + encodeURIComponent(state.key),
|
|
512
|
+
tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
|
|
513
|
+
onCount: (n, scope) => { if (scope !== 'run') return; state.findingsCount.set(state.key, n); },
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function findingsLink(key) {
|
|
517
|
+
const a = el('a', undefined, 'details-btn findings-link'); a.href = '#/runs/' + encodeURIComponent(key) + '/findings';
|
|
518
|
+
a.title = 'The findings this run left unfixed: dismiss them or file them as issues';
|
|
519
|
+
const n = state.findingsCount.get(key);
|
|
520
|
+
a.append(icon('panel-right'), el('span', 'Open findings'), el('span', n === undefined ? '' : String(n), 'findings-count'));
|
|
521
|
+
return a;
|
|
522
|
+
}
|
|
523
|
+
|
|
439
524
|
/* ---------- a run ---------- */
|
|
440
525
|
function viewToggle() {
|
|
441
526
|
const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
|
|
@@ -449,6 +534,7 @@ window.ForgeUI = (() => {
|
|
|
449
534
|
function syncToggles() {
|
|
450
535
|
for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
|
|
451
536
|
for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
|
|
537
|
+
syncLayerKeys();
|
|
452
538
|
}
|
|
453
539
|
function setMode(mode) {
|
|
454
540
|
if (mode === state.mode) return;
|
|
@@ -457,15 +543,20 @@ window.ForgeUI = (() => {
|
|
|
457
543
|
syncToggles();
|
|
458
544
|
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
459
545
|
}
|
|
460
|
-
|
|
546
|
+
// the header's last row: the push-button bank that sets what the stage shows, then the replay's time base
|
|
547
|
+
function toolbar(header) {
|
|
461
548
|
const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
|
|
462
549
|
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', '
|
|
550
|
+
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
551
|
['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
|
|
465
552
|
const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
|
|
466
553
|
}
|
|
467
554
|
bar.append(modes, viewToggle());
|
|
468
|
-
const ov = el('div', undefined, 'overlays');
|
|
555
|
+
const ov = el('div', undefined, 'overlays popover'); ov.popover = 'auto'; ov.id = 'view-pop'; ov.setAttribute('aria-label', 'What the stage shows');
|
|
556
|
+
ov.append(el('span', 'View', 'plate'));
|
|
557
|
+
const viewBtn = el('button', undefined, 'view-btn'); viewBtn.type = 'button'; viewBtn.popoverTargetElement = ov; viewBtn.setAttribute('aria-haspopup', 'true');
|
|
558
|
+
viewBtn.append(icon('layers'), el('span', 'View'), icon('chevron-up')); viewBtn.title = 'Height, colour, layers and marks';
|
|
559
|
+
ov.addEventListener('toggle', e => { if (e.newState === 'open') { syncLayerKeys(); placePopover(viewBtn, ov); } });
|
|
469
560
|
const sel = (label, key, entries) => {
|
|
470
561
|
const l = el('label', undefined, 'filter'); l.append(el('span', label));
|
|
471
562
|
const s = el('select'); s.dataset.ov = key;
|
|
@@ -474,35 +565,101 @@ window.ForgeUI = (() => {
|
|
|
474
565
|
};
|
|
475
566
|
sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
|
|
476
567
|
sel('Colour', 'color', [['status', 'status'], ['model', 'model'], ['duration', 'duration'], ['tokens', 'tokens']]);
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
568
|
+
// push buttons, one per part of the drawing: its lamp lights while that part is drawn
|
|
569
|
+
const bankOf = (title, rows, attr) => {
|
|
570
|
+
const g = el('div', undefined, 'layer-bank'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', title);
|
|
571
|
+
for (const [key, label, tip] of rows) {
|
|
572
|
+
const b = el('button', label, 'layer-key'); b.type = 'button'; b.dataset[attr] = key; b.title = tip; g.append(b);
|
|
573
|
+
}
|
|
574
|
+
ov.append(el('span', title, 'plate'), g);
|
|
575
|
+
return g;
|
|
576
|
+
};
|
|
577
|
+
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'],
|
|
578
|
+
['lamps', 'Lamps', 'The lamps on each block: one per attempt'], ['roof', 'Roof text', 'Task id, title, models and finding counts on each roof'],
|
|
579
|
+
['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'],
|
|
580
|
+
['finish', 'Finish line', 'Integration, verification and the final review'], ['districts', 'Districts', 'The wave and repository names on the plate'],
|
|
581
|
+
['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');
|
|
582
|
+
bankOf('Marks', [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'],
|
|
583
|
+
['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']], 'ov');
|
|
584
|
+
ov.addEventListener('click', e => {
|
|
585
|
+
const b = e.target.closest('.layer-key'); if (!b) return;
|
|
586
|
+
if (b.dataset.layer) {
|
|
587
|
+
const k = b.dataset.layer; state.layers[k] = !state.layers[k]; store.set('layers', state.layers);
|
|
588
|
+
if (state.stage) state.stage.setLayers({ [k]: state.layers[k] });
|
|
589
|
+
} else {
|
|
590
|
+
const k = b.dataset.ov; state.overlay[k] = !state.overlay[k]; store.set('overlay', state.overlay);
|
|
591
|
+
if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
|
|
592
|
+
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
593
|
+
}
|
|
594
|
+
syncLayerKeys();
|
|
595
|
+
});
|
|
482
596
|
ov.addEventListener('input', e => {
|
|
483
597
|
const k = e.target.dataset.ov; if (!k) return;
|
|
484
|
-
state.overlay[k] = e.target.
|
|
598
|
+
state.overlay[k] = e.target.value;
|
|
485
599
|
store.set('overlay', state.overlay);
|
|
486
600
|
if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
|
|
487
601
|
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
488
602
|
});
|
|
489
|
-
bar.append(ov);
|
|
603
|
+
bar.append(viewBtn, ov);
|
|
490
604
|
const rp = el('div', undefined, 'replay'); rp.id = 'replay';
|
|
491
|
-
const play = el('button',
|
|
605
|
+
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
606
|
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
607
|
const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
|
|
494
608
|
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'
|
|
609
|
+
const live = el('button'); live.type = 'button'; live.dataset.act = 'live'; live.append(icon('live'), el('span', 'Live'));
|
|
496
610
|
const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
|
|
497
611
|
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); });
|
|
612
|
+
rp.addEventListener('click', e => { const a = e.target.closest('[data-act]')?.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
|
|
499
613
|
rp.addEventListener('input', e => {
|
|
500
614
|
const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
|
|
501
615
|
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
616
|
if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
|
|
503
617
|
});
|
|
504
|
-
|
|
505
|
-
|
|
618
|
+
// the header's action slot: the run's record opens in the inspector, the export opens its dialog
|
|
619
|
+
const acts = el('div', undefined, 'run-actions');
|
|
620
|
+
const rec = el('button', undefined, 'details-btn'); rec.type = 'button'; rec.title = 'Settings, request, waves and verification output, in the inspector (2)';
|
|
621
|
+
rec.append(icon('panel-right'), el('span', 'Run details'));
|
|
622
|
+
rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
|
|
623
|
+
acts.append(rec);
|
|
624
|
+
if (!SNAP && window.ForgeFindings) acts.append(findingsLink(state.key));
|
|
625
|
+
if (!SNAP) {
|
|
626
|
+
const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
|
|
627
|
+
ex.append(icon('download'), el('span', 'Export…'));
|
|
628
|
+
ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
|
|
629
|
+
}
|
|
630
|
+
// the delete key stands apart from the routine ones, and only a server that takes writes shows it
|
|
631
|
+
if (!SNAP && window.ForgeRunDelete && window.ForgeUI && window.ForgeUI.controlsEnabled) {
|
|
632
|
+
window.ForgeUI.controlsEnabled().then(on => {
|
|
633
|
+
if (!on || acts.querySelector('.del-key')) return;
|
|
634
|
+
const del = el('button', undefined, 'del-key'); del.type = 'button'; del.title = 'Delete this run, after a dry run and a typed confirmation';
|
|
635
|
+
del.append(el('i', undefined, 'lamp st-fail'), el('span', 'Delete run…'));
|
|
636
|
+
del.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) window.ForgeRunDelete.open(run, { onDeleted: runDeleted }); });
|
|
637
|
+
acts.append(el('span', undefined, 'del-gap'), del);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
bar.append(rp, acts);
|
|
641
|
+
header.append(bar);
|
|
642
|
+
}
|
|
643
|
+
// after a delete: the run is gone, so the page returns to the run list and drops what it held of the run
|
|
644
|
+
function runDeleted(run) {
|
|
645
|
+
state.cache.delete(run.key);
|
|
646
|
+
if (location.hash !== '#/') location.hash = '#/';
|
|
647
|
+
state.listText = null;
|
|
648
|
+
loadList();
|
|
649
|
+
}
|
|
650
|
+
// the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
|
|
651
|
+
function syncLayerKeys() {
|
|
652
|
+
for (const b of document.querySelectorAll('.layer-key')) {
|
|
653
|
+
const on = b.dataset.layer ? !!state.layers[b.dataset.layer] : !!state.overlay[b.dataset.ov];
|
|
654
|
+
b.setAttribute('aria-pressed', String(on));
|
|
655
|
+
if (b.dataset.layer) b.disabled = !use3d();
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
// a popover beside the button that opened it: above when the button sits low on the screen
|
|
659
|
+
function placePopover(btn, pop) {
|
|
660
|
+
const r = btn.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight, below = window.innerHeight - r.bottom > h + 16;
|
|
661
|
+
pop.style.left = Math.round(Math.max(8, Math.min(window.innerWidth - w - 8, r.left))) + 'px';
|
|
662
|
+
pop.style.top = Math.round(below ? r.bottom + 6 : Math.max(8, r.top - h - 6)) + 'px';
|
|
506
663
|
}
|
|
507
664
|
function syncReplay(run) {
|
|
508
665
|
const rp = $('replay'); if (!rp) return;
|
|
@@ -511,8 +668,9 @@ window.ForgeUI = (() => {
|
|
|
511
668
|
if (!r) return;
|
|
512
669
|
const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
|
|
513
670
|
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]')
|
|
671
|
+
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
|
|
672
|
+
const playBtn = rp.querySelector('[data-act=play]');
|
|
673
|
+
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
674
|
rp.querySelector('[data-act=live]').disabled = state.replay === null;
|
|
517
675
|
}
|
|
518
676
|
function setReplay(t) {
|
|
@@ -521,19 +679,25 @@ window.ForgeUI = (() => {
|
|
|
521
679
|
if (state.stage) state.stage.setReplay(state.replay);
|
|
522
680
|
const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
|
|
523
681
|
}
|
|
524
|
-
function stopPlay() { if (state.playing) {
|
|
682
|
+
function stopPlay() { if (state.playing) { cancelAnimationFrame(state.playing); state.playing = null; } }
|
|
683
|
+
// the replay runs on the display's frames: the stage moves every frame, the panels around it four times a second
|
|
525
684
|
function togglePlay() {
|
|
526
685
|
const run = state.cache.get(state.key)?.data; if (!run) return;
|
|
527
686
|
if (state.playing) { stopPlay(); syncReplay(run); return; }
|
|
528
687
|
const r = RM.timeRange(run, now()); if (!r) return;
|
|
529
688
|
if (state.replay === null || state.replay >= r.end) state.replay = r.start;
|
|
530
|
-
|
|
689
|
+
let last = performance.now(), painted = 0;
|
|
690
|
+
const step = t0 => {
|
|
531
691
|
const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
|
|
532
692
|
if (!r2) { stopPlay(); return; }
|
|
533
|
-
const t = Math.min(r2.end, state.replay + state.speed * .
|
|
534
|
-
|
|
535
|
-
if (
|
|
536
|
-
|
|
693
|
+
const t = Math.min(r2.end, state.replay + state.speed * Math.min(.25, (t0 - last) / 1000)); last = t0;
|
|
694
|
+
state.replay = t;
|
|
695
|
+
if (state.stage && use3d()) state.stage.setReplay(t);
|
|
696
|
+
if (t0 - painted > 250 || t >= r2.end || !use3d()) { painted = t0; renderRunBody(run2); } else syncReplay(run2);
|
|
697
|
+
if (t >= r2.end) { state.playing = null; syncReplay(run2); return; }
|
|
698
|
+
state.playing = requestAnimationFrame(step);
|
|
699
|
+
};
|
|
700
|
+
state.playing = requestAnimationFrame(step);
|
|
537
701
|
syncReplay(run);
|
|
538
702
|
}
|
|
539
703
|
// A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
|
|
@@ -543,60 +707,74 @@ window.ForgeUI = (() => {
|
|
|
543
707
|
return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
|
|
544
708
|
}
|
|
545
709
|
function renderHeader(run) {
|
|
546
|
-
const head = $('run-head');
|
|
710
|
+
const head = $('run-head').querySelector('.run-head-body');
|
|
547
711
|
preserve(head, () => {
|
|
548
712
|
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);
|
|
713
|
+
// one line: the run's lamp, its goal, where it ran, and the describer
|
|
714
|
+
const row = el('div', undefined, 'run-title-row');
|
|
715
|
+
const lamp = el('i', undefined, 'lamp st-' + LAMP[RM.runState(run)]);
|
|
716
|
+
const title = el('h2', String(run.goal || 'Untitled run').split('\n')[0]); title.title = run.goal || '';
|
|
717
|
+
const where = el('span', undefined, 'run-where'); where.title = `${run.repo || ''}\n${run.run_id || ''}`;
|
|
718
|
+
where.append(el('span', RM.basename(run.repo), 'repo'), document.createTextNode(' '), el('span', run.run_id, 'mono'));
|
|
719
|
+
if (solo) where.append(document.createTextNode(' · solo run'));
|
|
720
|
+
// the describer: each state's count, the wall time so far, and how long since anything moved
|
|
721
|
+
const desc = el('span', undefined, 'describer');
|
|
722
|
+
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); }
|
|
723
|
+
const r = RM.timeRange(run, now());
|
|
724
|
+
if (r) desc.append(el('span', `WALL ${RM.fmtDur(r.end - r.start)}`, 'dim'));
|
|
725
|
+
const upd = el('span', relative(run.updated).replace(/^updated /, ''), 'dim updated'); upd.title = 'Last activity ' + time(run.updated);
|
|
726
|
+
if (run.updated != null) { upd.dataset.epoch = run.updated; upd.dataset.short = ''; }
|
|
727
|
+
desc.append(upd);
|
|
728
|
+
row.append(lamp, title, where, desc); head.append(row);
|
|
572
729
|
if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
|
|
573
730
|
if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
|
|
574
731
|
scoped(head, run.key + ':');
|
|
575
732
|
});
|
|
576
733
|
}
|
|
734
|
+
const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
735
|
+
const DIFF_RANK = { low: 0, medium: 1, high: 2 };
|
|
736
|
+
function taskColumns(run) {
|
|
737
|
+
const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
|
|
738
|
+
const busy = task => RM.taskTimes(task, null, now()).busy || null;
|
|
739
|
+
return [
|
|
740
|
+
{ id: 'id', label: 'ID', mono: true, value: t => t.id, cell: t => link(t.id, run.key, { kind: 'task', id: t.id }) },
|
|
741
|
+
{ id: 'title', label: 'Title', value: t => t.title },
|
|
742
|
+
{ id: 'difficulty', label: 'Difficulty', value: t => DIFF_RANK[t.difficulty] ?? null, cell: t => display(t.difficulty) },
|
|
743
|
+
{ 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) },
|
|
744
|
+
{ id: 'status', label: 'Status', value: t => t.status, cell: t => chip(t.status) },
|
|
745
|
+
{ id: 'dwarf', label: 'Dwarf', value: t => t.dwarf, cell: t => window.ForgeIcons.modelPlate(t.dwarf) },
|
|
746
|
+
{ id: 'qa', label: 'QA', value: t => t.qa, cell: t => window.ForgeIcons.modelPlate(t.qa) },
|
|
747
|
+
{ id: 'attempt', label: 'Attempt', value: t => Number(t.attempt) || null, cell: t => display(t.attempt) },
|
|
748
|
+
{ id: 'verdict', label: 'Verdict', value: t => t.verdict, cell: t => chip(t.verdict) },
|
|
749
|
+
{ id: 'findings', label: 'Findings', title: 'The worst confirmed severity and how many; ~n plausible only',
|
|
750
|
+
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,
|
|
751
|
+
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; } },
|
|
752
|
+
{ id: 'busy', label: 'Busy', value: busy, cell: t => RM.fmtDur(busy(t)) },
|
|
753
|
+
{ id: 'explored', label: '* Explored / propensity', hidden: true, title: 'Tasks whose model forge drew at random, with the pick\'s propensity',
|
|
754
|
+
value: t => t.explored ? 1 : null,
|
|
755
|
+
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=—') : '—'; } },
|
|
756
|
+
{ id: 'deps', label: 'Deps', hidden: true, value: t => list(t.deps).length || null,
|
|
757
|
+
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; } },
|
|
758
|
+
{ id: 'branch', label: 'Branch', mono: true, hidden: true, value: t => t.branch },
|
|
759
|
+
];
|
|
760
|
+
}
|
|
577
761
|
function renderTable(run) {
|
|
578
|
-
const host = $('run-tasks');
|
|
762
|
+
const host = $('run-tasks'); if (!host) return;
|
|
763
|
+
const box = host.closest('.tasks-module'), count = box && box.querySelector('.module-count');
|
|
764
|
+
if (count) count.textContent = run.kind === 'solo' ? '' : String(list(run.tasks).length);
|
|
765
|
+
const tools = box && box.querySelector('.module-tools');
|
|
766
|
+
let slot = tools && tools.querySelector('.dt-tools');
|
|
767
|
+
if (tools && !slot) { slot = el('span', undefined, 'dt-tools'); tools.prepend(slot); }
|
|
579
768
|
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
|
-
});
|
|
769
|
+
if (run.kind === 'solo') { if (slot) slot.replaceChildren(); return; }
|
|
770
|
+
if (!list(run.tasks).length) { if (slot) slot.replaceChildren(); host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
|
|
771
|
+
Table.render(host, {
|
|
772
|
+
id: 'tasks', label: 'Tasks', columns: taskColumns(run), rows: list(run.tasks), tools: slot, scrollKey: 'Tasks',
|
|
773
|
+
prefs: state.tablePrefs.tasks, savePrefs: p => { state.tablePrefs = { ...state.tablePrefs, tasks: p }; store.set('tables', state.tablePrefs); },
|
|
774
|
+
rowClass: t => state.sel && (state.sel.id === t.id || state.sel.task === t.id) ? 'selected' : '',
|
|
775
|
+
onRow: (t, tr) => tr.addEventListener('click', event => {
|
|
776
|
+
if (!event.target.closest('a, button') && !window.getSelection()?.toString()) select({ kind: 'task', id: t.id });
|
|
777
|
+
}),
|
|
600
778
|
});
|
|
601
779
|
scoped(host, run.key + ':');
|
|
602
780
|
});
|
|
@@ -610,6 +788,7 @@ window.ForgeUI = (() => {
|
|
|
610
788
|
fetchJSON: path => fetchJSON(path).then(r => r.data),
|
|
611
789
|
rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
|
|
612
790
|
hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
|
|
791
|
+
convoHash: s => convoHash(run.key, s),
|
|
613
792
|
onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
|
|
614
793
|
});
|
|
615
794
|
scoped(host, run.key + ':');
|
|
@@ -654,6 +833,15 @@ window.ForgeUI = (() => {
|
|
|
654
833
|
diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
|
|
655
834
|
prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
|
|
656
835
|
}
|
|
836
|
+
// every review's findings of the run, for the stage's marks: read again when the run changes
|
|
837
|
+
function syncFindings(run) {
|
|
838
|
+
const key = run.key, c = state.findings.get(key) || {};
|
|
839
|
+
if (c.data && state.stage) state.stage.setFindings(c.data);
|
|
840
|
+
if (c.updated === run.updated || c.pending === run.updated) return;
|
|
841
|
+
c.pending = run.updated; state.findings.set(key, c);
|
|
842
|
+
fetchJSON(`/api/runs/${encodeURIComponent(key)}/findings`).then(r => { c.data = r.data; }, () => {})
|
|
843
|
+
.finally(() => { c.updated = c.pending; c.pending = null; if (state.key === key && state.stage && c.data) state.stage.setFindings(c.data); });
|
|
844
|
+
}
|
|
657
845
|
function loadFiles(run) {
|
|
658
846
|
const key = run.key, updated = run.updated, c = state.files.get(key) || {};
|
|
659
847
|
if (c.pending === updated) return;
|
|
@@ -661,14 +849,22 @@ window.ForgeUI = (() => {
|
|
|
661
849
|
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
850
|
.finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
|
|
663
851
|
}
|
|
664
|
-
// the diff viewer takes the stage's place while it is open
|
|
852
|
+
// the diff viewer, or an execution's conversation, takes the stage's place while it is open
|
|
665
853
|
function renderDiff(run) {
|
|
666
854
|
const host = $('diff-view'), view = $('run-view'); if (!host || !view) return;
|
|
667
|
-
const
|
|
668
|
-
view.classList.toggle('diff-open',
|
|
669
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
855
|
+
const kind = state.convo && window.ForgeConvo ? 'convo' : state.diff && window.ForgeDiff ? 'diff' : null;
|
|
856
|
+
view.classList.toggle('diff-open', !!kind); host.hidden = !kind;
|
|
857
|
+
if (host.dataset.kind !== (kind || '')) { host.replaceChildren(); host.diffSig = ''; host.convoFrame = null; host.dataset.kind = kind || ''; }
|
|
858
|
+
if (!kind) return;
|
|
859
|
+
const rerender = () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); };
|
|
860
|
+
if (kind === 'convo') {
|
|
861
|
+
const sel = state.sel, task = sel.task ? list(run.tasks).find(t => t.id === sel.task) : run.kind === 'solo' ? list(run.tasks)[0] : null;
|
|
862
|
+
const brief = list(task && task.executions).find(e => e.id === state.convo.exec) || null;
|
|
863
|
+
window.ForgeConvo.render(host, { run, spec: { task: task ? task.id : null, exec: state.convo.exec }, brief, live: !SNAP,
|
|
864
|
+
fetchJSON: path => fetchJSON(path).then(r => r.data), rerender, diffHash: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data), rerender,
|
|
672
868
|
href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
|
|
673
869
|
}
|
|
674
870
|
function renderRunBody(run) {
|
|
@@ -676,7 +872,7 @@ window.ForgeUI = (() => {
|
|
|
676
872
|
syncToggles();
|
|
677
873
|
syncReplay(run);
|
|
678
874
|
placeStage($('stage-slot'));
|
|
679
|
-
if (use3d() && state.stage) state.stage.showRun(run, { mode: state.stageMode, sel: state.sel });
|
|
875
|
+
if (use3d() && state.stage) { state.stage.showRun(run, { mode: state.stageMode, sel: state.sel }); syncFindings(run); }
|
|
680
876
|
render2d(run);
|
|
681
877
|
renderStrip(run);
|
|
682
878
|
renderFiles(run);
|
|
@@ -687,24 +883,32 @@ window.ForgeUI = (() => {
|
|
|
687
883
|
if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
|
|
688
884
|
stopPlay(); state.replay = null;
|
|
689
885
|
if (state.stage) state.stage.setReplay(null);
|
|
690
|
-
main
|
|
886
|
+
setView(main, 'run'); main.dataset.run = run.key;
|
|
691
887
|
main.replaceChildren();
|
|
692
|
-
const head = el('section', undefined, 'run-header'); head.id = 'run-head'; main.append(head);
|
|
693
888
|
const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
|
|
694
|
-
|
|
889
|
+
const head = el('section', undefined, 'run-header'); head.id = 'run-head'; head.dataset.overlay = '';
|
|
890
|
+
head.append(el('div', undefined, 'run-head-body')); view.append(head);
|
|
891
|
+
toolbar(head);
|
|
892
|
+
// what floats below the header (the 2D views) starts under it, however many rows it wraps to
|
|
893
|
+
if (window.ResizeObserver) new ResizeObserver(() => document.body.style.setProperty('--head-h', head.offsetHeight + 'px')).observe(head);
|
|
695
894
|
const row = el('div', undefined, 'stage-row');
|
|
696
895
|
const col = el('div', undefined, 'stage-col');
|
|
697
896
|
const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
|
|
698
897
|
const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
|
|
699
898
|
const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
|
|
700
899
|
col.append(slot, g2, files);
|
|
701
|
-
const
|
|
702
|
-
|
|
900
|
+
const module = el('aside', undefined, 'module insp-module'); module.setAttribute('aria-label', 'Inspector'); module.dataset.overlay = '';
|
|
901
|
+
const insp = el('div', undefined, 'inspector'); insp.id = 'inspector'; insp.tabIndex = -1;
|
|
902
|
+
module.append(Shell.head('insp', 'Inspector'), insp, Shell.splitter('insp'));
|
|
903
|
+
row.append(col, module); view.append(row);
|
|
703
904
|
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
|
-
|
|
905
|
+
const diff = el('section', undefined, 'diff-view'); diff.id = 'diff-view'; diff.hidden = true; diff.tabIndex = -1; view.append(diff);
|
|
906
|
+
const box = el('section', undefined, 'module tasks-module'); box.setAttribute('aria-label', 'Tasks'); box.dataset.overlay = '';
|
|
907
|
+
const tasks = el('div', undefined, 'run-tasks'); tasks.id = 'run-tasks';
|
|
908
|
+
box.append(Shell.head('tasks', 'Tasks', ''), tasks, Shell.splitter('tasks'));
|
|
909
|
+
view.append(box);
|
|
707
910
|
}
|
|
911
|
+
document.body.classList.toggle('no-tasks', run.kind === 'solo');
|
|
708
912
|
ensureStage();
|
|
709
913
|
renderHeader(run);
|
|
710
914
|
renderRunBody(run);
|
|
@@ -713,8 +917,11 @@ window.ForgeUI = (() => {
|
|
|
713
917
|
}
|
|
714
918
|
function renderMain() {
|
|
715
919
|
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
920
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
716
921
|
if (state.cloud) { renderCloud(); return; }
|
|
717
922
|
if (state.models) { renderModels(); return; }
|
|
923
|
+
if (state.settings) { renderSettings(); return; }
|
|
924
|
+
if (state.key && state.findingsPage) { renderFindings(); return; }
|
|
718
925
|
if (!state.key) { renderMap(); return; }
|
|
719
926
|
const cached = state.cache.get(state.key);
|
|
720
927
|
if (cached) renderRun(cached.data);
|
|
@@ -765,22 +972,23 @@ window.ForgeUI = (() => {
|
|
|
765
972
|
return request('/api/runs', result => {
|
|
766
973
|
if (result.text === state.listText) return;
|
|
767
974
|
state.runs = list(result.data.runs);
|
|
768
|
-
state.listText = result.text;
|
|
975
|
+
state.listText = result.text; state.listLoaded = true;
|
|
769
976
|
if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
|
|
770
977
|
renderList();
|
|
771
978
|
if (state.cloud) renderCloud();
|
|
772
|
-
else if (
|
|
979
|
+
else if (state.settings) state.settingsView?.poll(); // the picker follows the run list; the page stays
|
|
980
|
+
else if (!state.key && !state.models) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
|
|
773
981
|
}, 'Could not refresh runs');
|
|
774
982
|
}
|
|
775
983
|
function loadRun(key) {
|
|
776
984
|
return request('/api/runs/' + encodeURIComponent(key), result => {
|
|
777
985
|
const changed = state.cache.get(key)?.text !== result.text;
|
|
778
986
|
state.cache.set(key, result);
|
|
779
|
-
if (state.key === key && changed) renderRun(result.data);
|
|
987
|
+
if (state.key === key && changed && !state.findingsPage) renderRun(result.data);
|
|
780
988
|
}, 'Could not refresh run', error => {
|
|
781
|
-
if (state.key !== key || state.cache.has(key)) return;
|
|
989
|
+
if (state.key !== key || state.cache.has(key) || state.findingsPage) return;
|
|
782
990
|
if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
|
|
783
|
-
$('main')
|
|
991
|
+
setView($('main'), 'waiting');
|
|
784
992
|
$('main').replaceChildren(el('p', error.status === 404 ?
|
|
785
993
|
'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
|
|
786
994
|
'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
|
|
@@ -793,36 +1001,47 @@ window.ForgeUI = (() => {
|
|
|
793
1001
|
history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
|
|
794
1002
|
parsed = parseHash(location.hash);
|
|
795
1003
|
}
|
|
796
|
-
const keyChanged = parsed.key !== state.key;
|
|
1004
|
+
const keyChanged = parsed.key !== state.key || (!!state.findingsPage && !parsed.findings); // the findings page replaced the run's DOM
|
|
797
1005
|
state.key = parsed.key;
|
|
1006
|
+
const selWas = JSON.stringify(state.sel || null);
|
|
798
1007
|
state.sel = parsed.sel;
|
|
799
|
-
|
|
1008
|
+
// a selection is read in the inspector, so choosing one opens it
|
|
1009
|
+
if (state.sel && JSON.stringify(state.sel) !== selWas && document.body.dataset.insp !== 'open') Shell.toggle('insp', true);
|
|
1010
|
+
const diffWas = JSON.stringify(state.diff || null), convoWas = JSON.stringify(state.convo || null);
|
|
800
1011
|
state.diff = parsed.diff || null;
|
|
1012
|
+
state.convo = parsed.convo || null;
|
|
801
1013
|
state.cloud = parsed.cloud || null;
|
|
802
1014
|
state.models = !!parsed.models;
|
|
1015
|
+
state.settings = !!parsed.settings && !!window.ForgeSettings && !SNAP;
|
|
1016
|
+
if (parsed.key) state.lastKey = parsed.key;
|
|
1017
|
+
state.findingsPage = !!parsed.findings && !!window.ForgeFindings;
|
|
803
1018
|
if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
|
|
804
1019
|
cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
|
|
805
1020
|
history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
|
|
806
1021
|
}
|
|
807
1022
|
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
1023
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
808
1024
|
for (const a of document.querySelectorAll('.topbar .top-link')) {
|
|
809
|
-
const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
|
|
1025
|
+
const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : state.settings ? '#/settings' : !state.key ? '#/' : null);
|
|
810
1026
|
if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
|
811
1027
|
}
|
|
812
1028
|
renderErrors();
|
|
813
1029
|
markSelection();
|
|
814
1030
|
if (state.cloud) { renderCloud(); return; }
|
|
815
1031
|
if (state.models) { renderModels(); loadModels(); return; }
|
|
1032
|
+
if (state.settings) { renderSettings(); return; }
|
|
1033
|
+
if (state.key && state.findingsPage) { renderFindings(); return; }
|
|
816
1034
|
if (!state.key) { renderMap(); return; }
|
|
817
1035
|
const cached = state.cache.get(state.key);
|
|
818
1036
|
if (cached) {
|
|
819
1037
|
if (keyChanged) renderRun(cached.data);
|
|
820
1038
|
else { renderRunBody(cached.data); renderTable(cached.data); }
|
|
821
|
-
} else { $('main')
|
|
1039
|
+
} else { setView($('main'), 'loading'); $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
|
|
822
1040
|
loadRun(state.key);
|
|
823
1041
|
// 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 =
|
|
1042
|
+
if (state.diff || state.convo) {
|
|
1043
|
+
const opened = state.convo ? convoWas !== JSON.stringify(state.convo) :
|
|
1044
|
+
diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
|
|
826
1045
|
if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
|
|
827
1046
|
} else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
|
|
828
1047
|
}
|
|
@@ -840,7 +1059,8 @@ window.ForgeUI = (() => {
|
|
|
840
1059
|
loadList();
|
|
841
1060
|
if (state.key) loadRun(state.key);
|
|
842
1061
|
if (state.models) loadModels();
|
|
843
|
-
|
|
1062
|
+
if (state.settings) state.settingsView?.poll();
|
|
1063
|
+
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
1064
|
}
|
|
845
1065
|
// Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
|
|
846
1066
|
function schedule() {
|
|
@@ -855,12 +1075,14 @@ window.ForgeUI = (() => {
|
|
|
855
1075
|
{ id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
|
|
856
1076
|
{ id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
|
|
857
1077
|
{ id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
|
|
1078
|
+
...(window.ForgeSettings ? [{ id: 'cmd:settings', label: 'Open the settings', sub: 'model registry, Jev, and the .forge files of a repository', ids: ['settings', 'config', 'verify', 'jev'], href: '#/settings' }] : []),
|
|
858
1079
|
{ id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
|
|
859
1080
|
{ id: 'cmd:dim', label: state.dim === '3d' ? 'Switch to 2D' : 'Switch to 3D', sub: state.dim === '3d' ? 'graphs and charts, no WebGL' : 'loads three.js from cdn.jsdelivr.net', ids: ['2d', '3d'],
|
|
860
1081
|
action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
|
|
861
1082
|
{ id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
|
|
862
1083
|
action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
|
|
863
1084
|
];
|
|
1085
|
+
if (state.key && !SNAP && window.ForgeFindings) out.push({ id: 'cmd:findings', label: 'Open findings of this run…', sub: 'dismiss them or file them as GitHub issues', ids: ['findings', 'issues', 'dismiss'], href: '#/runs/' + encodeURIComponent(state.key) + '/findings' });
|
|
864
1086
|
if (run && !SNAP) out.push({ id: 'cmd:export', label: 'Export this run as one HTML file…', sub: 'to share; opens without forge-ui', ids: ['export', 'share'], action: () => exportDialog(run) });
|
|
865
1087
|
if (SNAP) return out.filter(c => c.id === 'cmd:dim').concat(run ? [{ id: 'cmd:mode', label: state.mode === 'plan' ? 'Show the execution' : 'Show the planned waves', sub: '', ids: ['exec', 'plan'], action: () => setMode(state.mode === 'plan' ? 'exec' : 'plan') }] : []);
|
|
866
1088
|
if (run && window.ForgeFiles) out.push({ id: 'cmd:files', label: 'Show which tasks touched which files', sub: 'the Files view of this run', ids: ['files', 'heatmap'], action: () => setMode('files') });
|
|
@@ -911,8 +1133,10 @@ window.ForgeUI = (() => {
|
|
|
911
1133
|
if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
|
|
912
1134
|
if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
|
|
913
1135
|
$('filter').addEventListener('input', event => { state.mapFilter.text = event.target.value; filterChanged(true); });
|
|
1136
|
+
$('run-sort').addEventListener('change', event => { state.runSort = event.target.value; store.set('run-sort', state.runSort); renderList(); });
|
|
914
1137
|
$('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
|
|
915
1138
|
window.addEventListener('hashchange', navigate);
|
|
1139
|
+
if (Shell) Shell.onChange(() => { if (state.stage && !state.cloud) requestAnimationFrame(() => state.stage.reframe && state.stage.reframe()); });
|
|
916
1140
|
document.addEventListener('visibilitychange', refresh);
|
|
917
1141
|
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
1142
|
loadList();
|
|
@@ -922,3 +1146,6 @@ window.ForgeUI = (() => {
|
|
|
922
1146
|
init();
|
|
923
1147
|
return { refresh, state };
|
|
924
1148
|
})();
|
|
1149
|
+
// window.ForgeUI is uikit.js's shared helpers; the page's own handles (state, refresh) join it, where the browser
|
|
1150
|
+
// tests and ui/tools/readme_media.py read them.
|
|
1151
|
+
window.ForgeUI = Object.assign(window.ForgeUI || {}, window.ForgeApp);
|