@giamme/forge 0.0.0-stage → 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 +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- 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 +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -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 +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -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 +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
/* forge-ui: read-only API viewer. All API values enter the DOM as text.
|
|
2
|
+
Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
|
|
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
|
+
so the 3D scene (and its WebGL context) survives navigation and refreshes. */
|
|
5
|
+
window.ForgeUI = (() => {
|
|
6
|
+
'use strict';
|
|
7
|
+
const RM = window.ForgeRunModel;
|
|
8
|
+
// an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
|
|
9
|
+
const SNAP = (() => { const n = document.getElementById('forge-snapshot'); if (!n) return null; try { const v = JSON.parse(n.textContent); return v && v.api ? v : null; } catch (_) { return null; } })();
|
|
10
|
+
const store = {
|
|
11
|
+
get(k, d) { try { const v = localStorage.getItem('forge-ui-' + k); return v === null ? d : JSON.parse(v); } catch (_) { return d; } },
|
|
12
|
+
set(k, v) { try { localStorage.setItem('forge-ui-' + k, JSON.stringify(v)); } catch (_) { /* private mode: not remembered */ } },
|
|
13
|
+
};
|
|
14
|
+
const state = { runs: [], listText: null, cache: new Map(), key: null, sel: null, paused: false, errors: new Map(), pending: new Map(),
|
|
15
|
+
mapFilter: Object.assign({ text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }, store.get('map-filter', {})),
|
|
16
|
+
showAll: false, mode: store.get('mode', 'plan'), dim: store.get('view', '3d'),
|
|
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', {})),
|
|
19
|
+
replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
|
|
20
|
+
lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
|
|
21
|
+
models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
|
|
22
|
+
files: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
|
|
23
|
+
findings: new Map() };
|
|
24
|
+
state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
|
|
25
|
+
if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
|
|
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; }
|
|
33
|
+
const display = value => value === null || value === undefined || value === '' ? '—' :
|
|
34
|
+
typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
|
35
|
+
const list = value => Array.isArray(value) ? value : [];
|
|
36
|
+
const wall = () => Date.now() / 1000;
|
|
37
|
+
const now = () => { const run = state.key && state.cache.get(state.key)?.data; return run ? RM.liveNow(run, Date.now() / 1000) : Date.now() / 1000; };
|
|
38
|
+
function el(tag, text, className) {
|
|
39
|
+
const node = document.createElement(tag);
|
|
40
|
+
if (text !== undefined) node.textContent = display(text);
|
|
41
|
+
if (className) node.className = className;
|
|
42
|
+
return node;
|
|
43
|
+
}
|
|
44
|
+
/* ---------- routes ---------- */
|
|
45
|
+
function hashFor(key, sel) {
|
|
46
|
+
if (key == null) return '#/';
|
|
47
|
+
const base = '#/runs/' + encodeURIComponent(key);
|
|
48
|
+
if (!sel) return base;
|
|
49
|
+
const e = encodeURIComponent;
|
|
50
|
+
if (sel.kind === 'task') return `${base}/tasks/${e(sel.id)}`;
|
|
51
|
+
if (sel.kind === 'execution') return sel.task ? `${base}/tasks/${e(sel.task)}/executions/${e(sel.exec)}` : `${base}/executions/${e(sel.exec)}`;
|
|
52
|
+
if (sel.kind === 'final') return `${base}/final-review${sel.name ? '/' + e(sel.name) : ''}`;
|
|
53
|
+
if (sel.kind === 'verification') return `${base}/verification`;
|
|
54
|
+
if (sel.kind === 'integration') return `${base}/integration`;
|
|
55
|
+
if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
|
|
56
|
+
return base;
|
|
57
|
+
}
|
|
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)
|
|
60
|
+
function diffHash(key, spec) {
|
|
61
|
+
const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.finding ? '/finding/' + e(spec.finding) : spec.file ? '/file/' + e(spec.file) : '';
|
|
62
|
+
if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
|
|
63
|
+
if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
|
|
64
|
+
return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
|
|
65
|
+
}
|
|
66
|
+
// an execution's conversation, in the stage's place like a diff
|
|
67
|
+
const convoHash = (key, sel) => hashFor(key, sel) + '/conversation';
|
|
68
|
+
function cloudHash(sel, focus) {
|
|
69
|
+
if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
|
|
70
|
+
return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
|
|
71
|
+
}
|
|
72
|
+
function parseHash(hash) {
|
|
73
|
+
const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
|
|
74
|
+
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 } };
|
|
75
|
+
if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
|
|
76
|
+
const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
|
|
77
|
+
if (!m) return { key: null, sel: null };
|
|
78
|
+
const d = decodeURIComponent, rest = m[2] || '';
|
|
79
|
+
let sel = null, r;
|
|
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 } };
|
|
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 } };
|
|
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
|
+
}
|
|
89
|
+
if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
|
|
90
|
+
if (sel && sel.kind === 'execution' && sel.task) {
|
|
91
|
+
const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
|
|
92
|
+
if (t && list(t.shadow).some(x => x.execution === sel.exec)) sel.shadow = true;
|
|
93
|
+
}
|
|
94
|
+
else if ((r = rest.match(/^\/tasks\/([^/]+)\/?$/))) sel = { kind: 'task', id: d(r[1]) };
|
|
95
|
+
else if ((r = rest.match(/^\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: null, exec: d(r[1]) };
|
|
96
|
+
else if ((r = rest.match(/^\/final-review(?:\/([^/]+))?\/?$/))) sel = { kind: 'final', name: r[1] ? d(r[1]) : null };
|
|
97
|
+
else if (/^\/verification\/?$/.test(rest)) sel = { kind: 'verification' };
|
|
98
|
+
else if (/^\/integration\/?$/.test(rest)) sel = { kind: 'integration' };
|
|
99
|
+
else if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/?$/))) sel = { kind: 'fractal', task: d(r[1]), node: d(r[2]) };
|
|
100
|
+
return { key: d(m[1]), sel };
|
|
101
|
+
}
|
|
102
|
+
const select = sel => { location.hash = hashFor(state.key, sel); };
|
|
103
|
+
function link(text, key, sel) { const a = el('a', text); a.href = hashFor(key, sel); return a; }
|
|
104
|
+
|
|
105
|
+
const statuses = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra',
|
|
106
|
+
'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict']);
|
|
107
|
+
function chip(value) {
|
|
108
|
+
const status = String(value || 'unknown').toLowerCase();
|
|
109
|
+
return el('span', value || '—', 'chip st-' + (statuses.has(status) ? status : 'unknown'));
|
|
110
|
+
}
|
|
111
|
+
function time(value) {
|
|
112
|
+
if (value == null || !Number.isFinite(Number(value))) return '—';
|
|
113
|
+
const date = new Date(Number(value) * 1000);
|
|
114
|
+
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
|
115
|
+
}
|
|
116
|
+
function relative(value) {
|
|
117
|
+
if (value == null || !Number.isFinite(Number(value))) return 'updated —';
|
|
118
|
+
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - Number(value)));
|
|
119
|
+
if (seconds < 60) return 'updated just now';
|
|
120
|
+
if (seconds < 3600) return `updated ${Math.floor(seconds / 60)}m ago`;
|
|
121
|
+
if (seconds < 86400) return `updated ${Math.floor(seconds / 3600)}h ago`;
|
|
122
|
+
return `updated ${Math.floor(seconds / 86400)}d ago`;
|
|
123
|
+
}
|
|
124
|
+
function section(parent, title, id) {
|
|
125
|
+
const node = el('section');
|
|
126
|
+
if (id) node.id = id;
|
|
127
|
+
if (title) node.append(el('h2', title));
|
|
128
|
+
parent.append(node);
|
|
129
|
+
return node;
|
|
130
|
+
}
|
|
131
|
+
function details(parent, title, value, key) {
|
|
132
|
+
const node = el('details');
|
|
133
|
+
node.dataset.keep = key;
|
|
134
|
+
const pre = el('pre', value);
|
|
135
|
+
pre.dataset.scroll = key + '-text';
|
|
136
|
+
node.append(el('summary', title), pre);
|
|
137
|
+
parent.append(node);
|
|
138
|
+
return node;
|
|
139
|
+
}
|
|
140
|
+
function table(parent, headers, rows, key) {
|
|
141
|
+
const wrap = el('div', undefined, 'table-scroll');
|
|
142
|
+
wrap.dataset.scroll = key;
|
|
143
|
+
wrap.tabIndex = 0;
|
|
144
|
+
wrap.setAttribute('role', 'region');
|
|
145
|
+
wrap.setAttribute('aria-label', key);
|
|
146
|
+
const node = el('table');
|
|
147
|
+
const head = el('tr');
|
|
148
|
+
for (const label of headers) { const th = el('th', label); th.scope = 'col'; head.append(th); }
|
|
149
|
+
const thead = el('thead');
|
|
150
|
+
thead.append(head);
|
|
151
|
+
const body = el('tbody');
|
|
152
|
+
for (const cells of rows) {
|
|
153
|
+
const tr = el('tr');
|
|
154
|
+
for (const [index, value] of cells.entries()) {
|
|
155
|
+
const mono = ['ID', 'Dwarf', 'QA', 'Branch'].includes(headers[index]);
|
|
156
|
+
const td = el('td', undefined, mono ? 'mono' : '');
|
|
157
|
+
td.append(value instanceof Node ? value : document.createTextNode(display(value)));
|
|
158
|
+
tr.append(td);
|
|
159
|
+
}
|
|
160
|
+
body.append(tr);
|
|
161
|
+
}
|
|
162
|
+
node.append(thead, body);
|
|
163
|
+
wrap.append(node);
|
|
164
|
+
parent.append(wrap);
|
|
165
|
+
return body;
|
|
166
|
+
}
|
|
167
|
+
// Restore disclosures, internal scroll and keyboard focus after changed data arrives.
|
|
168
|
+
function preserve(parent, build, { restore = true } = {}) {
|
|
169
|
+
const open = new Map(Array.from(parent.querySelectorAll('details[data-keep]'), n => [n.dataset.keep, n.open]));
|
|
170
|
+
const scroll = new Map(Array.from(parent.querySelectorAll('[data-scroll]'), n => [n.dataset.scroll, [n.scrollLeft, n.scrollTop]]));
|
|
171
|
+
const active = parent.contains(document.activeElement) ? document.activeElement : null;
|
|
172
|
+
const focusHref = active?.getAttribute('href');
|
|
173
|
+
const own = [parent.scrollLeft, parent.scrollTop];
|
|
174
|
+
parent.replaceChildren();
|
|
175
|
+
build();
|
|
176
|
+
if (!restore) return;
|
|
177
|
+
for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
|
|
178
|
+
for (const node of parent.querySelectorAll('[data-scroll]')) {
|
|
179
|
+
const pos = scroll.get(node.dataset.scroll);
|
|
180
|
+
if (pos) { node.scrollLeft = pos[0]; node.scrollTop = pos[1]; }
|
|
181
|
+
}
|
|
182
|
+
parent.scrollLeft = own[0]; parent.scrollTop = own[1];
|
|
183
|
+
if (focusHref) Array.from(parent.querySelectorAll('a')).find(n => n.getAttribute('href') === focusHref)?.focus({ preventScroll: true });
|
|
184
|
+
}
|
|
185
|
+
function scoped(parent, scope) {
|
|
186
|
+
for (const node of parent.querySelectorAll('[data-keep], [data-scroll]')) {
|
|
187
|
+
if (node.dataset.keep && !node.dataset.keep.startsWith(scope)) node.dataset.keep = scope + node.dataset.keep;
|
|
188
|
+
if (node.dataset.scroll && !node.dataset.scroll.startsWith(scope)) node.dataset.scroll = scope + node.dataset.scroll;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ---------- errors and the run list ---------- */
|
|
193
|
+
function currentResource(path) {
|
|
194
|
+
return path === '/api/runs' || (state.models && path === '/api/models') || (state.key != null && path === '/api/runs/' + encodeURIComponent(state.key));
|
|
195
|
+
}
|
|
196
|
+
function renderErrors() {
|
|
197
|
+
for (const path of state.errors.keys()) if (!currentResource(path)) state.errors.delete(path);
|
|
198
|
+
const banner = $('errors');
|
|
199
|
+
banner.textContent = Array.from(state.errors.values()).join(' · ');
|
|
200
|
+
banner.hidden = !state.errors.size;
|
|
201
|
+
}
|
|
202
|
+
function filteredRuns() {
|
|
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);
|
|
207
|
+
}
|
|
208
|
+
function renderList() {
|
|
209
|
+
const runs = filteredRuns();
|
|
210
|
+
$('run-count').textContent = `${runs.length}/${state.runs.length}`;
|
|
211
|
+
if ($('run-sort').value !== state.runSort) $('run-sort').value = state.runSort;
|
|
212
|
+
if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
|
|
213
|
+
preserve($('run-list'), () => {
|
|
214
|
+
if (!runs.length) {
|
|
215
|
+
const empty = el('div', undefined, 'empty');
|
|
216
|
+
empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
|
|
217
|
+
if (!state.runs.length) {
|
|
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.'));
|
|
219
|
+
empty.append(el('p', 'Open runs kept somewhere else:'), el('pre', 'forge ui open <run-dir>', 'cmd'));
|
|
220
|
+
}
|
|
221
|
+
$('run-list').append(empty);
|
|
222
|
+
}
|
|
223
|
+
for (const run of runs) {
|
|
224
|
+
const item = link(undefined, run.key);
|
|
225
|
+
item.className = 'run-item';
|
|
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];
|
|
229
|
+
const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
|
|
230
|
+
goal.title = run.goal || 'Untitled run';
|
|
231
|
+
const solo = run.kind === 'solo';
|
|
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 = ''; }
|
|
235
|
+
updated.title = time(run.updated);
|
|
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'));
|
|
239
|
+
const counts = el('div', undefined, 'chips');
|
|
240
|
+
for (const [status, count] of Object.entries(run.counts || {})) {
|
|
241
|
+
const badge = chip(status);
|
|
242
|
+
badge.append(document.createTextNode(' ' + display(count)));
|
|
243
|
+
counts.append(badge);
|
|
244
|
+
}
|
|
245
|
+
item.append(counts);
|
|
246
|
+
if (!solo) {
|
|
247
|
+
const verification = el('span', undefined, 'verification');
|
|
248
|
+
verification.append(document.createTextNode('Verification '), chip(run.verification_status));
|
|
249
|
+
if (run.final_review) verification.append(document.createTextNode(' Review '), chip(run.final_review));
|
|
250
|
+
item.append(verification);
|
|
251
|
+
}
|
|
252
|
+
$('run-list').append(item);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
markSelection();
|
|
256
|
+
}
|
|
257
|
+
function markSelection() {
|
|
258
|
+
for (const node of $('run-list').querySelectorAll('[data-run]')) {
|
|
259
|
+
const selected = node.dataset.run === state.key;
|
|
260
|
+
node.classList.toggle('selected', selected);
|
|
261
|
+
if (selected) node.setAttribute('aria-current', 'page'); else node.removeAttribute('aria-current');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ---------- the stage: one element, 3D when it can be, else the 2D views ---------- */
|
|
266
|
+
const stageHost = el('div', undefined, 'stage');
|
|
267
|
+
stageHost.id = 'stage-host';
|
|
268
|
+
const stageNote = el('p', undefined, 'muted stage-note');
|
|
269
|
+
const use3d = () => state.dim === '3d' && !state.stageFailed;
|
|
270
|
+
function ensureStage() {
|
|
271
|
+
if (!use3d() || state.stage || state.stageLoading || !window.ForgeMap3D) return;
|
|
272
|
+
state.stageLoading = true;
|
|
273
|
+
window.ForgeMap3D.load().then(mod => mod.createStage(stageHost, {
|
|
274
|
+
onSelect: sel => select(sel),
|
|
275
|
+
onOpenRun: key => { location.hash = hashFor(key, null); },
|
|
276
|
+
onBack: () => { location.hash = '#/'; },
|
|
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
|
+
},
|
|
284
|
+
cloud: {
|
|
285
|
+
onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
|
|
286
|
+
onFocus: pid => { location.hash = cloudHash(null, pid); },
|
|
287
|
+
onOpenRun: key => { location.hash = hashFor(key, null); },
|
|
288
|
+
onMoved: () => state.cloudUI && state.cloudUI.refreshCard(),
|
|
289
|
+
onLayout: name => state.cloudUI && state.cloudUI.setLayout(name),
|
|
290
|
+
onShape: shape => state.cloudUI && state.cloudUI.setShape(shape),
|
|
291
|
+
},
|
|
292
|
+
})).then(stage => {
|
|
293
|
+
state.stage = stage; state.stageLoading = false;
|
|
294
|
+
stage.setOverlay(state.overlay);
|
|
295
|
+
stage.setLayers(state.layers);
|
|
296
|
+
stage.setReplay(state.replay);
|
|
297
|
+
renderMain();
|
|
298
|
+
}, err => {
|
|
299
|
+
state.stageLoading = false;
|
|
300
|
+
if (window.ForgeMap3D.state === 'ready') window.ForgeMap3D.reason = 'stage';
|
|
301
|
+
state.stageFailed = window.ForgeMap3D.reason || 'load';
|
|
302
|
+
if (err && window.console) console.warn('forge-ui: 3D view unavailable', err);
|
|
303
|
+
renderMain();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function placeStage(slot) {
|
|
307
|
+
if (!slot) return;
|
|
308
|
+
const show = use3d();
|
|
309
|
+
if (stageHost.parentNode !== slot) slot.append(stageHost);
|
|
310
|
+
stageHost.hidden = !show || !state.stage;
|
|
311
|
+
slot.classList.toggle('stage-on', show && !!state.stage);
|
|
312
|
+
slot.classList.toggle('stage-loading', show && !state.stage);
|
|
313
|
+
stageNote.textContent = state.stageFailed && state.dim === '3d' ? window.ForgeMap3D.message() : show && !state.stage ? 'Loading the 3D view…' : '';
|
|
314
|
+
if (stageNote.parentNode !== slot.parentNode) slot.after(stageNote);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---------- the map of every run ---------- */
|
|
318
|
+
function filterBar(parent) {
|
|
319
|
+
const f = state.mapFilter, opts = RM.filterOptions(state.runs);
|
|
320
|
+
const bar = el('form', undefined, 'filters');
|
|
321
|
+
bar.setAttribute('aria-label', 'Filter the runs on the map');
|
|
322
|
+
bar.addEventListener('submit', e => e.preventDefault());
|
|
323
|
+
const add = (label, control) => { const l = el('label', undefined, 'filter'); l.append(el('span', label), control); bar.append(l); return control; };
|
|
324
|
+
const text = el('input'); text.type = 'search'; text.value = f.text; text.placeholder = 'goal, repo, id, model…'; text.dataset.f = 'text';
|
|
325
|
+
add('Search', text);
|
|
326
|
+
const sel = (key, entries) => {
|
|
327
|
+
const s = el('select'); s.dataset.f = key;
|
|
328
|
+
for (const [v, label] of entries) { const o = el('option', label); o.value = v; if (String(f[key]) === String(v)) o.selected = true; s.append(o); }
|
|
329
|
+
return s;
|
|
330
|
+
};
|
|
331
|
+
add('State', sel('state', [['', 'any'], ['live', 'running now'], ['failed', 'has failures'], ['done', 'all done'], ['pending', 'not started']]));
|
|
332
|
+
add('Repo', sel('repo', [['', 'any'], ...opts.repos.map(r => [r.path, r.name])]));
|
|
333
|
+
add('Kind', sel('kind', [['', 'any'], ['plan', 'plan'], ['solo', 'solo']]));
|
|
334
|
+
add('Model', sel('model', [['', 'any'], ...opts.models.map(m => [m, m])]));
|
|
335
|
+
add('Age', sel('ageDays', [[0, 'any'], [1, 'last day'], [7, 'last week'], [30, 'last month']]));
|
|
336
|
+
add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']]));
|
|
337
|
+
const all = el('input'); all.type = 'checkbox'; all.checked = state.showAll; all.dataset.f = 'showAll';
|
|
338
|
+
const allL = el('label', undefined, 'filter check'); allL.append(all, document.createTextNode(' older runs too')); bar.append(allL);
|
|
339
|
+
const reset = el('button', 'Reset'); reset.type = 'button'; reset.addEventListener('click', () => { Object.assign(state.mapFilter, { text: '', state: '', repo: '', kind: '', model: '', ageDays: 0, verdict: '' }); state.showAll = false; filterChanged(true); });
|
|
340
|
+
bar.append(reset);
|
|
341
|
+
bar.addEventListener('input', e => {
|
|
342
|
+
const k = e.target.dataset.f; if (!k) return;
|
|
343
|
+
if (k === 'showAll') state.showAll = e.target.checked;
|
|
344
|
+
else state.mapFilter[k] = k === 'ageDays' ? Number(e.target.value) : e.target.value;
|
|
345
|
+
filterChanged(false);
|
|
346
|
+
});
|
|
347
|
+
parent.append(bar);
|
|
348
|
+
}
|
|
349
|
+
function filterChanged(rebuildBar) {
|
|
350
|
+
store.set('map-filter', state.mapFilter);
|
|
351
|
+
renderList();
|
|
352
|
+
if (!state.key && !state.cloud) renderMap({ rebuildBar });
|
|
353
|
+
}
|
|
354
|
+
function mapCards(parent) {
|
|
355
|
+
const A = RM.atlasModel(state.runs, state.mapFilter, { now: wall(), showAll: state.showAll });
|
|
356
|
+
const wrap = el('div', undefined, 'map-cards');
|
|
357
|
+
for (const layer of A.layers) {
|
|
358
|
+
const g = el('section', undefined, 'map-repo');
|
|
359
|
+
g.append(el('h3', layer.name));
|
|
360
|
+
const grid = el('div', undefined, 'map-grid');
|
|
361
|
+
for (const m of A.modules.filter(x => x.layer === layer.id)) {
|
|
362
|
+
const r = m.run, card = link(undefined, r.key);
|
|
363
|
+
card.className = `map-card st-${({ live: 'running', failed: 'fail', done: 'merged', pending: 'pending' })[m.state]}${m.match ? '' : ' dim'}`;
|
|
364
|
+
card.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
|
|
365
|
+
const leds = el('span', undefined, 'leds');
|
|
366
|
+
for (const t of m.tasks.slice(0, 60)) { const l = el('i', undefined, 'led st-' + String(t.status || 'pending').toLowerCase()); l.title = `${t.id}: ${t.status}`; leds.append(l); }
|
|
367
|
+
card.append(leds, el('span', relative(r.updated), 'muted'));
|
|
368
|
+
grid.append(card);
|
|
369
|
+
}
|
|
370
|
+
g.append(grid); wrap.append(g);
|
|
371
|
+
}
|
|
372
|
+
parent.append(wrap);
|
|
373
|
+
return A;
|
|
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
|
+
}
|
|
392
|
+
function renderMap({ rebuildBar = true } = {}) {
|
|
393
|
+
const main = $('main');
|
|
394
|
+
if (state.listLoaded && !state.runs.length && !SNAP) { renderFirstRun(); return; }
|
|
395
|
+
if (main.dataset.view !== 'map') {
|
|
396
|
+
setView(main, 'map'); delete main.dataset.run;
|
|
397
|
+
main.replaceChildren();
|
|
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 = '';
|
|
400
|
+
const head = el('div', undefined, 'map-head');
|
|
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);
|
|
410
|
+
const slot = el('div', undefined, 'stage-slot map'); slot.id = 'stage-slot'; v.append(slot);
|
|
411
|
+
const cards = el('div', undefined, 'map-cards-host'); cards.id = 'map-cards'; v.append(cards);
|
|
412
|
+
rebuildBar = true;
|
|
413
|
+
}
|
|
414
|
+
if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
|
|
415
|
+
syncToggles();
|
|
416
|
+
ensureStage();
|
|
417
|
+
placeStage($('stage-slot'));
|
|
418
|
+
let info;
|
|
419
|
+
if (use3d() && state.stage) {
|
|
420
|
+
info = state.stage.showAtlas(state.runs, state.mapFilter, { showAll: state.showAll });
|
|
421
|
+
$('map-cards').replaceChildren();
|
|
422
|
+
} else {
|
|
423
|
+
preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
|
|
424
|
+
}
|
|
425
|
+
const counter = $('map-view').querySelector('.map-head .map-count');
|
|
426
|
+
if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------- the run cloud: every run in 3D, full screen ---------- */
|
|
430
|
+
function renderCloud() {
|
|
431
|
+
const main = $('main');
|
|
432
|
+
if (main.dataset.view !== 'cloud' || !state.cloudUI) {
|
|
433
|
+
setView(main, 'cloud'); delete main.dataset.run;
|
|
434
|
+
main.replaceChildren();
|
|
435
|
+
const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
|
|
436
|
+
const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
|
|
437
|
+
main.append(v);
|
|
438
|
+
state.cloudUI = window.ForgeCloudUI.create(v, {
|
|
439
|
+
prefs: state.cloudPrefs,
|
|
440
|
+
save: p => { state.cloudPrefs = p; store.set('cloud', p); },
|
|
441
|
+
route: ({ sel, focus }) => { location.hash = cloudHash(sel, focus); },
|
|
442
|
+
load3d: () => { state.dim = '3d'; store.set('view', '3d'); state.stageFailed = null; renderMain(); },
|
|
443
|
+
viewToggle,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
ensureStage();
|
|
447
|
+
placeStage($('stage-slot'));
|
|
448
|
+
stageNote.textContent = '';
|
|
449
|
+
const mode = use3d() ? (state.stage ? '3d' : 'loading') : state.stageFailed && state.dim === '3d' ? 'failed' : '2d';
|
|
450
|
+
state.cloudUI.render({ runs: state.runs, sel: state.cloud.sel, focus: state.cloud.focus, mode, stage: mode === '3d' ? state.stage : null,
|
|
451
|
+
note: mode === 'failed' ? window.ForgeMap3D.message() : '' });
|
|
452
|
+
syncToggles();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* ---------- the model leaderboard (#/models) ---------- */
|
|
456
|
+
function renderModels() {
|
|
457
|
+
const main = $('main');
|
|
458
|
+
if (main.dataset.view !== 'models') {
|
|
459
|
+
setView(main, 'models'); delete main.dataset.run;
|
|
460
|
+
main.replaceChildren();
|
|
461
|
+
const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
|
|
462
|
+
}
|
|
463
|
+
const host = $('models-view');
|
|
464
|
+
if (!state.modelsData) {
|
|
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'));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
window.ForgeLeaderboard.render(host, state.modelsData, {
|
|
469
|
+
runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
|
|
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); },
|
|
472
|
+
modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function loadModels() {
|
|
476
|
+
return request('/api/models', result => {
|
|
477
|
+
state.modelsError = null;
|
|
478
|
+
if (result.text === state.modelsText) return;
|
|
479
|
+
state.modelsText = result.text; state.modelsData = result.data;
|
|
480
|
+
if (state.models) renderModels();
|
|
481
|
+
}, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ---------- a run ---------- */
|
|
485
|
+
function viewToggle() {
|
|
486
|
+
const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
|
|
487
|
+
for (const [v, label] of [['3d', '3D'], ['2d', '2D']]) {
|
|
488
|
+
const b = el('button', label); b.type = 'button'; b.dataset.dim = v;
|
|
489
|
+
b.addEventListener('click', () => { state.dim = v; store.set('view', v); if (v === '3d') state.stageFailed = null; renderMain(); });
|
|
490
|
+
g.append(b);
|
|
491
|
+
}
|
|
492
|
+
return g;
|
|
493
|
+
}
|
|
494
|
+
function syncToggles() {
|
|
495
|
+
for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
|
|
496
|
+
for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
|
|
497
|
+
syncLayerKeys();
|
|
498
|
+
}
|
|
499
|
+
function setMode(mode) {
|
|
500
|
+
if (mode === state.mode) return;
|
|
501
|
+
state.mode = mode; store.set('mode', mode);
|
|
502
|
+
if (mode !== 'files') { state.stageMode = mode; if (state.stage) state.stage.setMode(mode); }
|
|
503
|
+
syncToggles();
|
|
504
|
+
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
505
|
+
}
|
|
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) {
|
|
508
|
+
const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
|
|
509
|
+
const modes = el('div', undefined, 'seg'); modes.setAttribute('role', 'group'); modes.setAttribute('aria-label', 'Plan or execution');
|
|
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)'],
|
|
511
|
+
['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
|
|
512
|
+
const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
|
|
513
|
+
}
|
|
514
|
+
bar.append(modes, viewToggle());
|
|
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); } });
|
|
520
|
+
const sel = (label, key, entries) => {
|
|
521
|
+
const l = el('label', undefined, 'filter'); l.append(el('span', label));
|
|
522
|
+
const s = el('select'); s.dataset.ov = key;
|
|
523
|
+
for (const [v, t] of entries) { const o = el('option', t); o.value = v; if (state.overlay[key] === v) o.selected = true; s.append(o); }
|
|
524
|
+
l.append(s); ov.append(l);
|
|
525
|
+
};
|
|
526
|
+
sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
|
|
527
|
+
sel('Colour', 'color', [['status', 'status'], ['model', 'model'], ['duration', 'duration'], ['tokens', 'tokens']]);
|
|
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
|
+
});
|
|
556
|
+
ov.addEventListener('input', e => {
|
|
557
|
+
const k = e.target.dataset.ov; if (!k) return;
|
|
558
|
+
state.overlay[k] = e.target.value;
|
|
559
|
+
store.set('overlay', state.overlay);
|
|
560
|
+
if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
|
|
561
|
+
const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
|
|
562
|
+
});
|
|
563
|
+
bar.append(viewBtn, ov);
|
|
564
|
+
const rp = el('div', undefined, 'replay'); rp.id = 'replay';
|
|
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';
|
|
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');
|
|
567
|
+
const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
|
|
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); }
|
|
569
|
+
const live = el('button'); live.type = 'button'; live.dataset.act = 'live'; live.append(icon('live'), el('span', 'Live'));
|
|
570
|
+
const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
|
|
571
|
+
rp.append(play, range, speed, live, at);
|
|
572
|
+
rp.addEventListener('click', e => { const a = e.target.closest('[data-act]')?.dataset.act; if (a === 'play') togglePlay(); if (a === 'live') setReplay(null); });
|
|
573
|
+
rp.addEventListener('input', e => {
|
|
574
|
+
const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
|
|
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); }
|
|
576
|
+
if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
|
|
577
|
+
});
|
|
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';
|
|
605
|
+
}
|
|
606
|
+
function syncReplay(run) {
|
|
607
|
+
const rp = $('replay'); if (!rp) return;
|
|
608
|
+
const r = RM.timeRange(run, now());
|
|
609
|
+
rp.hidden = !r;
|
|
610
|
+
if (!r) return;
|
|
611
|
+
const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
|
|
612
|
+
range.value = String(state.replay === null ? 1000 : Math.round((state.replay - r.start) / Math.max(1, r.end - r.start) * 1000));
|
|
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'; }
|
|
616
|
+
rp.querySelector('[data-act=live]').disabled = state.replay === null;
|
|
617
|
+
}
|
|
618
|
+
function setReplay(t) {
|
|
619
|
+
state.replay = t === null ? null : t;
|
|
620
|
+
if (t === null) stopPlay();
|
|
621
|
+
if (state.stage) state.stage.setReplay(state.replay);
|
|
622
|
+
const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
|
|
623
|
+
}
|
|
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
|
|
626
|
+
function togglePlay() {
|
|
627
|
+
const run = state.cache.get(state.key)?.data; if (!run) return;
|
|
628
|
+
if (state.playing) { stopPlay(); syncReplay(run); return; }
|
|
629
|
+
const r = RM.timeRange(run, now()); if (!r) return;
|
|
630
|
+
if (state.replay === null || state.replay >= r.end) state.replay = r.start;
|
|
631
|
+
let last = performance.now(), painted = 0;
|
|
632
|
+
const step = t0 => {
|
|
633
|
+
const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
|
|
634
|
+
if (!r2) { stopPlay(); return; }
|
|
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);
|
|
643
|
+
syncReplay(run);
|
|
644
|
+
}
|
|
645
|
+
// A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
|
|
646
|
+
function atReplay(run) {
|
|
647
|
+
if (state.replay === null) return run;
|
|
648
|
+
const s = RM.stateAt(run, state.replay);
|
|
649
|
+
return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
|
|
650
|
+
}
|
|
651
|
+
function renderHeader(run) {
|
|
652
|
+
const head = $('run-head').querySelector('.run-head-body');
|
|
653
|
+
preserve(head, () => {
|
|
654
|
+
const solo = run.kind === 'solo';
|
|
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);
|
|
671
|
+
if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
|
|
672
|
+
if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
|
|
673
|
+
scoped(head, run.key + ':');
|
|
674
|
+
});
|
|
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
|
+
}
|
|
703
|
+
function renderTable(run) {
|
|
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); }
|
|
710
|
+
preserve(host, () => {
|
|
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
|
+
}),
|
|
720
|
+
});
|
|
721
|
+
scoped(host, run.key + ':');
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function renderInspector(run) {
|
|
725
|
+
const host = $('inspector'); if (!host || !window.ForgePanel) return;
|
|
726
|
+
const sel = state.sel || (run.kind === 'solo' ? { kind: 'task', id: list(run.tasks)[0]?.id } : null);
|
|
727
|
+
preserve(host, () => {
|
|
728
|
+
window.ForgePanel.render(host, {
|
|
729
|
+
run, sel, now: now(),
|
|
730
|
+
fetchJSON: path => fetchJSON(path).then(r => r.data),
|
|
731
|
+
rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
|
|
732
|
+
hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
|
|
733
|
+
convoHash: s => convoHash(run.key, s),
|
|
734
|
+
onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
|
|
735
|
+
});
|
|
736
|
+
scoped(host, run.key + ':');
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
function render2d(run) {
|
|
740
|
+
const host = $('graph2d'); if (!host) return;
|
|
741
|
+
const show2d = !use3d() || !state.stage;
|
|
742
|
+
host.hidden = !show2d;
|
|
743
|
+
if (!show2d) { host.replaceChildren(); return; }
|
|
744
|
+
preserve(host, () => {
|
|
745
|
+
const r = atReplay(run);
|
|
746
|
+
try {
|
|
747
|
+
if (state.stageMode === 'exec') window.ForgeGantt.render(host, run, { now: now(), onSelect: select, sel: state.sel, replay: state.replay, critical: state.overlay.critical ? RM.criticalPath(run, { now: now() }) : null, ghosts: state.overlay.ghosts });
|
|
748
|
+
else window.ForgeGraph.render(host, r, { selected: state.sel && (state.sel.task || state.sel.id), onSelect: id => select({ kind: 'task', id }) });
|
|
749
|
+
} catch (_) { host.replaceChildren(el('p', 'Graph unavailable. Select tasks in the table below.', 'muted')); }
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function renderStrip(run) {
|
|
753
|
+
const host = $('strip'); if (!host) return;
|
|
754
|
+
const show = state.mode === 'exec' && run.kind !== 'solo';
|
|
755
|
+
host.hidden = !show;
|
|
756
|
+
if (show) window.ForgeGantt.strip(host, run, { now: now(), replay: state.replay }); else host.replaceChildren();
|
|
757
|
+
}
|
|
758
|
+
// Files: which task touched which file, in the stage's place (the inspector stays beside it)
|
|
759
|
+
function renderFiles(run) {
|
|
760
|
+
const host = $('files-view'), view = $('run-view'); if (!host || !view) return;
|
|
761
|
+
const open = state.mode === 'files' && !!window.ForgeFiles;
|
|
762
|
+
view.classList.toggle('files-open', open); host.hidden = !open;
|
|
763
|
+
if (!open) { host.filesSig = ''; return; }
|
|
764
|
+
const c = state.files.get(run.key);
|
|
765
|
+
if (!c || c.updated !== run.updated) loadFiles(run);
|
|
766
|
+
if (!c || !c.data) {
|
|
767
|
+
host.filesSig = '';
|
|
768
|
+
host.replaceChildren(el('p', c && c.error ? `Could not load the files (${c.error}).` : 'Reading which files each task touched…', c && c.error ? 'warn' : 'muted'));
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const sig = JSON.stringify([c.at, state.sel]);
|
|
772
|
+
if (host.filesSig === sig) return; // a poll that changed nothing here redraws nothing
|
|
773
|
+
host.filesSig = sig;
|
|
774
|
+
window.ForgeFiles.render(host, c.data, { run, sel: state.sel, onSelectTask: id => select({ kind: 'task', id }),
|
|
775
|
+
diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
|
|
776
|
+
prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
|
|
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
|
+
}
|
|
787
|
+
function loadFiles(run) {
|
|
788
|
+
const key = run.key, updated = run.updated, c = state.files.get(key) || {};
|
|
789
|
+
if (c.pending === updated) return;
|
|
790
|
+
c.pending = updated; state.files.set(key, c);
|
|
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; })
|
|
792
|
+
.finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
|
|
793
|
+
}
|
|
794
|
+
// the diff viewer, or an execution's conversation, takes the stage's place while it is open
|
|
795
|
+
function renderDiff(run) {
|
|
796
|
+
const host = $('diff-view'), view = $('run-view'); if (!host || !view) return;
|
|
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,
|
|
810
|
+
href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
|
|
811
|
+
}
|
|
812
|
+
function renderRunBody(run) {
|
|
813
|
+
renderDiff(run);
|
|
814
|
+
syncToggles();
|
|
815
|
+
syncReplay(run);
|
|
816
|
+
placeStage($('stage-slot'));
|
|
817
|
+
if (use3d() && state.stage) { state.stage.showRun(run, { mode: state.stageMode, sel: state.sel }); syncFindings(run); }
|
|
818
|
+
render2d(run);
|
|
819
|
+
renderStrip(run);
|
|
820
|
+
renderFiles(run);
|
|
821
|
+
renderInspector(run);
|
|
822
|
+
}
|
|
823
|
+
function renderRun(run) {
|
|
824
|
+
const main = $('main');
|
|
825
|
+
if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
|
|
826
|
+
stopPlay(); state.replay = null;
|
|
827
|
+
if (state.stage) state.stage.setReplay(null);
|
|
828
|
+
setView(main, 'run'); main.dataset.run = run.key;
|
|
829
|
+
main.replaceChildren();
|
|
830
|
+
const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
|
|
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);
|
|
836
|
+
const row = el('div', undefined, 'stage-row');
|
|
837
|
+
const col = el('div', undefined, 'stage-col');
|
|
838
|
+
const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
|
|
839
|
+
const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
|
|
840
|
+
const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
|
|
841
|
+
col.append(slot, g2, files);
|
|
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);
|
|
846
|
+
const strip = el('div', undefined, 'strip-wrap'); strip.id = 'strip'; view.append(strip);
|
|
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);
|
|
852
|
+
}
|
|
853
|
+
document.body.classList.toggle('no-tasks', run.kind === 'solo');
|
|
854
|
+
ensureStage();
|
|
855
|
+
renderHeader(run);
|
|
856
|
+
renderRunBody(run);
|
|
857
|
+
renderTable(run);
|
|
858
|
+
announce(run);
|
|
859
|
+
}
|
|
860
|
+
function renderMain() {
|
|
861
|
+
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
862
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
863
|
+
if (state.cloud) { renderCloud(); return; }
|
|
864
|
+
if (state.models) { renderModels(); return; }
|
|
865
|
+
if (!state.key) { renderMap(); return; }
|
|
866
|
+
const cached = state.cache.get(state.key);
|
|
867
|
+
if (cached) renderRun(cached.data);
|
|
868
|
+
}
|
|
869
|
+
// A polite summary of what changed since the last poll, for screen readers.
|
|
870
|
+
function announce(run) {
|
|
871
|
+
const prev = state.lastStatuses.get(run.key), cur = new Map(list(run.tasks).map(t => [t.id, t.status]));
|
|
872
|
+
state.lastStatuses.set(run.key, cur);
|
|
873
|
+
if (!prev || Date.now() - state.announced < 10000) return;
|
|
874
|
+
const changes = [...cur].filter(([id, s]) => prev.has(id) && prev.get(id) !== s).map(([id, s]) => `${id} ${prev.get(id).toLowerCase()} → ${s.toLowerCase()}`);
|
|
875
|
+
if (!changes.length) return;
|
|
876
|
+
state.announced = Date.now();
|
|
877
|
+
$('live').textContent = changes.slice(0, 5).join('; ');
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/* ---------- data ---------- */
|
|
881
|
+
async function fetchJSON(path) {
|
|
882
|
+
if (SNAP) {
|
|
883
|
+
const data = SNAP.api[path];
|
|
884
|
+
if (data === undefined) { const error = new Error('not in this export'); error.status = 404; error.snapshot = true; throw error; }
|
|
885
|
+
return { text: JSON.stringify(data), data };
|
|
886
|
+
}
|
|
887
|
+
const response = await fetch(path, { cache: 'no-store' });
|
|
888
|
+
if (!response.ok) {
|
|
889
|
+
const error = new Error(`HTTP ${response.status}`);
|
|
890
|
+
error.status = response.status;
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
const text = await response.text();
|
|
894
|
+
return { text, data: JSON.parse(text) };
|
|
895
|
+
}
|
|
896
|
+
// Coalesce polling and navigation requests for the same resource.
|
|
897
|
+
function request(path, consume, label, onError) {
|
|
898
|
+
if (state.pending.has(path)) return state.pending.get(path);
|
|
899
|
+
const pending = fetchJSON(path).then(result => {
|
|
900
|
+
consume(result);
|
|
901
|
+
state.errors.delete(path);
|
|
902
|
+
}).catch(error => {
|
|
903
|
+
if (currentResource(path)) {
|
|
904
|
+
state.errors.set(path, `${label}: ${error.message}. Any last good data is kept. Refresh retries while enabled and this tab is visible.`);
|
|
905
|
+
onError?.(error);
|
|
906
|
+
}
|
|
907
|
+
}).finally(() => { state.pending.delete(path); renderErrors(); });
|
|
908
|
+
state.pending.set(path, pending);
|
|
909
|
+
return pending;
|
|
910
|
+
}
|
|
911
|
+
function loadList() {
|
|
912
|
+
return request('/api/runs', result => {
|
|
913
|
+
if (result.text === state.listText) return;
|
|
914
|
+
state.runs = list(result.data.runs);
|
|
915
|
+
state.listText = result.text; state.listLoaded = true;
|
|
916
|
+
if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
|
|
917
|
+
renderList();
|
|
918
|
+
if (state.cloud) renderCloud();
|
|
919
|
+
else if (!state.key) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
|
|
920
|
+
}, 'Could not refresh runs');
|
|
921
|
+
}
|
|
922
|
+
function loadRun(key) {
|
|
923
|
+
return request('/api/runs/' + encodeURIComponent(key), result => {
|
|
924
|
+
const changed = state.cache.get(key)?.text !== result.text;
|
|
925
|
+
state.cache.set(key, result);
|
|
926
|
+
if (state.key === key && changed) renderRun(result.data);
|
|
927
|
+
}, 'Could not refresh run', error => {
|
|
928
|
+
if (state.key !== key || state.cache.has(key)) return;
|
|
929
|
+
if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
|
|
930
|
+
setView($('main'), 'waiting');
|
|
931
|
+
$('main').replaceChildren(el('p', error.status === 404 ?
|
|
932
|
+
'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
|
|
933
|
+
'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
function navigate() {
|
|
937
|
+
let parsed = { key: null, sel: null };
|
|
938
|
+
try { parsed = parseHash(location.hash); } catch (_) { /* invalid escapes fall back to the map */ }
|
|
939
|
+
if (SNAP && parsed.key !== SNAP.meta.key) { // an export shows its one run
|
|
940
|
+
history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
|
|
941
|
+
parsed = parseHash(location.hash);
|
|
942
|
+
}
|
|
943
|
+
const keyChanged = parsed.key !== state.key;
|
|
944
|
+
state.key = parsed.key;
|
|
945
|
+
const selWas = JSON.stringify(state.sel || null);
|
|
946
|
+
state.sel = parsed.sel;
|
|
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);
|
|
950
|
+
state.diff = parsed.diff || null;
|
|
951
|
+
state.convo = parsed.convo || null;
|
|
952
|
+
state.cloud = parsed.cloud || null;
|
|
953
|
+
state.models = !!parsed.models;
|
|
954
|
+
if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
|
|
955
|
+
cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
|
|
956
|
+
history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
|
|
957
|
+
}
|
|
958
|
+
document.body.classList.toggle('cloud-mode', !!state.cloud);
|
|
959
|
+
if (!state.key) document.body.classList.remove('no-tasks');
|
|
960
|
+
for (const a of document.querySelectorAll('.topbar .top-link')) {
|
|
961
|
+
const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
|
|
962
|
+
if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
|
|
963
|
+
}
|
|
964
|
+
renderErrors();
|
|
965
|
+
markSelection();
|
|
966
|
+
if (state.cloud) { renderCloud(); return; }
|
|
967
|
+
if (state.models) { renderModels(); loadModels(); return; }
|
|
968
|
+
if (!state.key) { renderMap(); return; }
|
|
969
|
+
const cached = state.cache.get(state.key);
|
|
970
|
+
if (cached) {
|
|
971
|
+
if (keyChanged) renderRun(cached.data);
|
|
972
|
+
else { renderRunBody(cached.data); renderTable(cached.data); }
|
|
973
|
+
} else { setView($('main'), 'loading'); $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
|
|
974
|
+
loadRun(state.key);
|
|
975
|
+
// a selection made on the stage keeps the keyboard there (← → walk the tasks); one made elsewhere moves it to the inspector
|
|
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 });
|
|
979
|
+
if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
|
|
980
|
+
} else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
|
|
981
|
+
}
|
|
982
|
+
function busy() {
|
|
983
|
+
if (state.key) {
|
|
984
|
+
const run = state.cache.get(state.key)?.data;
|
|
985
|
+
return !!run && (list(run.tasks).some(t => t.status === 'RUNNING') || (run.final_review && run.final_review.state === 'running'));
|
|
986
|
+
}
|
|
987
|
+
return state.runs.some(r => r.counts && r.counts.RUNNING);
|
|
988
|
+
}
|
|
989
|
+
const background = () => document.visibilityState !== 'visible' && !!window.ForgeNotify && window.ForgeNotify.background();
|
|
990
|
+
function refresh() {
|
|
991
|
+
if (state.paused || SNAP) return;
|
|
992
|
+
if (document.visibilityState !== 'visible') { if (background()) loadList(); return; } // only to notify
|
|
993
|
+
loadList();
|
|
994
|
+
if (state.key) loadRun(state.key);
|
|
995
|
+
if (state.models) loadModels();
|
|
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; }
|
|
997
|
+
}
|
|
998
|
+
// Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
|
|
999
|
+
function schedule() {
|
|
1000
|
+
clearTimeout(state.timer);
|
|
1001
|
+
if (SNAP) return; // an export never changes
|
|
1002
|
+
state.timer = setTimeout(() => { refresh(); schedule(); }, background() ? 30000 : busy() ? 2000 : 5000);
|
|
1003
|
+
}
|
|
1004
|
+
// the quick jump's commands: what the page can do from anywhere
|
|
1005
|
+
function commands() {
|
|
1006
|
+
const run = state.key && state.cache.get(state.key)?.data;
|
|
1007
|
+
const out = [
|
|
1008
|
+
{ id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
|
|
1009
|
+
{ id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
|
|
1010
|
+
{ id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
|
|
1011
|
+
{ id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
|
|
1012
|
+
{ 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'],
|
|
1013
|
+
action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
|
|
1014
|
+
{ id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
|
|
1015
|
+
action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
|
|
1016
|
+
];
|
|
1017
|
+
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) });
|
|
1018
|
+
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') }] : []);
|
|
1019
|
+
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') });
|
|
1020
|
+
if (run) out.push({ id: 'cmd:mode', label: state.mode === 'plan' ? 'Show the execution' : 'Show the planned waves', sub: run.goal ? String(run.goal).split('\n')[0] : '', ids: ['exec', 'plan'], action: () => setMode(state.mode === 'plan' ? 'exec' : 'plan') });
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
function cloudFilter(filter) {
|
|
1024
|
+
const f = window.ForgeCloudModel.normFilter(filter);
|
|
1025
|
+
state.cloudPrefs = { ...state.cloudPrefs, filter: f }; store.set('cloud', state.cloudPrefs);
|
|
1026
|
+
if (state.cloud && state.cloudUI) state.cloudUI.setFilter(f);
|
|
1027
|
+
if (!state.cloud) location.hash = '#/cloud';
|
|
1028
|
+
}
|
|
1029
|
+
// what an export holds: chosen each time, private texts and paths off by default
|
|
1030
|
+
let exportDlg = null;
|
|
1031
|
+
function exportDialog(run) {
|
|
1032
|
+
if (!exportDlg) {
|
|
1033
|
+
exportDlg = el('dialog', undefined, 'export-dialog'); exportDlg.setAttribute('aria-label', 'Export this run');
|
|
1034
|
+
document.body.append(exportDlg);
|
|
1035
|
+
exportDlg.addEventListener('click', e => { if (e.target === exportDlg) exportDlg.close(); });
|
|
1036
|
+
}
|
|
1037
|
+
const d = exportDlg; d.replaceChildren();
|
|
1038
|
+
d.append(el('h2', 'Export this run as one HTML file'),
|
|
1039
|
+
el('p', 'The file opens in any browser without forge-ui: the run in 2D, and in 3D when it can reach cdn.jsdelivr.net. It holds the goal, task titles, statuses, models, timings, and each finding\'s headline.', 'muted'));
|
|
1040
|
+
const opt = (label, help, checked) => { const l = el('label', undefined, 'export-opt'), c = el('input'); c.type = 'checkbox'; c.checked = checked; const t = el('span'); t.append(el('strong', label), el('span', help, 'muted')); l.append(c, t); d.append(l); return c; };
|
|
1041
|
+
const texts = opt('Include prompts, diffs and reports', 'Prompts, reviewer reports, logs, finding details and diffs can contain code, secrets and private notes.', false);
|
|
1042
|
+
const hide = opt('Hide local paths', 'Paths on this machine are reduced to their last folder or file name.', true);
|
|
1043
|
+
const note = el('p', '', 'warn export-note');
|
|
1044
|
+
const go = el('a', 'Download', 'button primary'); go.setAttribute('download', '');
|
|
1045
|
+
const sync = () => {
|
|
1046
|
+
go.href = `/api/runs/${encodeURIComponent(run.key)}/export?texts=${texts.checked ? 1 : 0}&paths=${hide.checked ? 0 : 1}`;
|
|
1047
|
+
note.textContent = texts.checked ? 'Share this file only with people who may read this repository\'s code.' : '';
|
|
1048
|
+
};
|
|
1049
|
+
texts.addEventListener('change', sync); hide.addEventListener('change', sync); sync();
|
|
1050
|
+
const cancel = el('button', 'Cancel'); cancel.type = 'button'; cancel.addEventListener('click', () => d.close());
|
|
1051
|
+
go.addEventListener('click', () => setTimeout(() => d.close(), 300));
|
|
1052
|
+
const row = el('div', undefined, 'export-actions'); row.append(cancel, go);
|
|
1053
|
+
d.append(note, row);
|
|
1054
|
+
d.showModal(); go.focus();
|
|
1055
|
+
}
|
|
1056
|
+
function snapshotBanner() {
|
|
1057
|
+
const m = SNAP.meta, b = el('div', undefined, 'snapshot-banner'); b.setAttribute('role', 'note');
|
|
1058
|
+
const when = Number.isFinite(m.generated) ? new Date(m.generated * 1000).toLocaleString() : 'an unknown date';
|
|
1059
|
+
b.append(el('strong', 'Exported run'), document.createTextNode(` · from forge-ui ${m.app || ''} on ${when} · ${m.texts ? 'prompts, diffs and reports included' : 'prompts, diffs and reports left out'} · ${m.paths ? 'local paths shown' : 'local paths hidden'}${(m.omitted || []).length ? ` · ${m.omitted.length} large item${m.omitted.length === 1 ? '' : 's'} left out` : ''}`));
|
|
1060
|
+
document.querySelector('.topbar').after(b);
|
|
1061
|
+
}
|
|
1062
|
+
function init() {
|
|
1063
|
+
if (SNAP) { document.body.classList.add('snapshot'); snapshotBanner(); $('refresh').checked = false; }
|
|
1064
|
+
if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
|
|
1065
|
+
if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
|
|
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(); });
|
|
1068
|
+
$('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
|
|
1069
|
+
window.addEventListener('hashchange', navigate);
|
|
1070
|
+
if (Shell) Shell.onChange(() => { if (state.stage && !state.cloud) requestAnimationFrame(() => state.stage.reframe && state.stage.reframe()); });
|
|
1071
|
+
document.addEventListener('visibilitychange', refresh);
|
|
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); } });
|
|
1073
|
+
loadList();
|
|
1074
|
+
navigate();
|
|
1075
|
+
schedule();
|
|
1076
|
+
}
|
|
1077
|
+
init();
|
|
1078
|
+
return { refresh, state };
|
|
1079
|
+
})();
|