@giamme/forge 0.0.0-stage → 0.8.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,924 @@
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
+ replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
19
+ lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
20
+ models: false, modelsData: null, modelsText: null, modelsError: null, modelsPrefs: store.get('models', {}),
21
+ files: new Map(), filesPrefs: store.get('files', {}) };
22
+ state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
23
+ if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
24
+ const $ = id => document.getElementById(id);
25
+ const display = value => value === null || value === undefined || value === '' ? '—' :
26
+ typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
27
+ const list = value => Array.isArray(value) ? value : [];
28
+ const wall = () => Date.now() / 1000;
29
+ const now = () => { const run = state.key && state.cache.get(state.key)?.data; return run ? RM.liveNow(run, Date.now() / 1000) : Date.now() / 1000; };
30
+ function el(tag, text, className) {
31
+ const node = document.createElement(tag);
32
+ if (text !== undefined) node.textContent = display(text);
33
+ if (className) node.className = className;
34
+ return node;
35
+ }
36
+ /* ---------- routes ---------- */
37
+ function hashFor(key, sel) {
38
+ if (key == null) return '#/';
39
+ const base = '#/runs/' + encodeURIComponent(key);
40
+ if (!sel) return base;
41
+ const e = encodeURIComponent;
42
+ if (sel.kind === 'task') return `${base}/tasks/${e(sel.id)}`;
43
+ if (sel.kind === 'execution') return sel.task ? `${base}/tasks/${e(sel.task)}/executions/${e(sel.exec)}` : `${base}/executions/${e(sel.exec)}`;
44
+ if (sel.kind === 'final') return `${base}/final-review${sel.name ? '/' + e(sel.name) : ''}`;
45
+ if (sel.kind === 'verification') return `${base}/verification`;
46
+ if (sel.kind === 'integration') return `${base}/integration`;
47
+ if (sel.kind === 'fractal') return `${base}/tasks/${e(sel.task)}/fractal/${e(sel.node)}`;
48
+ return base;
49
+ }
50
+ // a diff: {kind: 'task', task, source, file} | {kind: 'final', name, file} | {kind: 'fractal', task, node, file}
51
+ function diffHash(key, spec) {
52
+ const e = encodeURIComponent, base = '#/runs/' + e(key), file = spec.file ? '/file/' + e(spec.file) : '';
53
+ if (spec.kind === 'final') return `${base}/final-review/${e(spec.name)}/diff${file}`;
54
+ if (spec.kind === 'fractal') return `${base}/tasks/${e(spec.task)}/fractal/${e(spec.node)}/diff${file}`;
55
+ return `${base}/tasks/${e(spec.task)}/diff${spec.source && spec.source !== 'current' ? '/' + e(spec.source) : ''}${file}`;
56
+ }
57
+ function cloudHash(sel, focus) {
58
+ if (sel) return '#/cloud/runs/' + encodeURIComponent(sel);
59
+ return focus ? '#/cloud/projects/' + encodeURIComponent(focus) : '#/cloud';
60
+ }
61
+ function parseHash(hash) {
62
+ const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
63
+ 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
+ if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
65
+ const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
66
+ if (!m) return { key: null, sel: null };
67
+ const d = decodeURIComponent, rest = m[2] || '';
68
+ 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', file: r[3] ? d(r[3]) : null } };
70
+ 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]), file: r[2] ? d(r[2]) : null } };
72
+ if ((r = rest.match(/^\/tasks\/([^/]+)\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: d(r[1]), id: d(r[1]), exec: d(r[2]) };
73
+ if (sel && sel.kind === 'execution' && sel.task) {
74
+ const run = state.cache.get(d(m[1]))?.data, t = run && list(run.tasks).find(x => x.id === sel.task);
75
+ if (t && list(t.shadow).some(x => x.execution === sel.exec)) sel.shadow = true;
76
+ }
77
+ else if ((r = rest.match(/^\/tasks\/([^/]+)\/?$/))) sel = { kind: 'task', id: d(r[1]) };
78
+ else if ((r = rest.match(/^\/executions\/([^/]+)\/?$/))) sel = { kind: 'execution', task: null, exec: d(r[1]) };
79
+ else if ((r = rest.match(/^\/final-review(?:\/([^/]+))?\/?$/))) sel = { kind: 'final', name: r[1] ? d(r[1]) : null };
80
+ else if (/^\/verification\/?$/.test(rest)) sel = { kind: 'verification' };
81
+ else if (/^\/integration\/?$/.test(rest)) sel = { kind: 'integration' };
82
+ else if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/?$/))) sel = { kind: 'fractal', task: d(r[1]), node: d(r[2]) };
83
+ return { key: d(m[1]), sel };
84
+ }
85
+ const select = sel => { location.hash = hashFor(state.key, sel); };
86
+ function link(text, key, sel) { const a = el('a', text); a.href = hashFor(key, sel); return a; }
87
+
88
+ const statuses = new Set(['merged', 'pass', 'running', 'pending', 'fail', 'error', 'infra',
89
+ 'timeout', 'unknown', 'invalidated', 'blocked', 'interrupted', 'nochanges', 'conflict']);
90
+ function chip(value) {
91
+ const status = String(value || 'unknown').toLowerCase();
92
+ return el('span', value || '—', 'chip st-' + (statuses.has(status) ? status : 'unknown'));
93
+ }
94
+ function time(value) {
95
+ if (value == null || !Number.isFinite(Number(value))) return '—';
96
+ const date = new Date(Number(value) * 1000);
97
+ return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
98
+ }
99
+ function relative(value) {
100
+ if (value == null || !Number.isFinite(Number(value))) return 'updated —';
101
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000 - Number(value)));
102
+ if (seconds < 60) return 'updated just now';
103
+ if (seconds < 3600) return `updated ${Math.floor(seconds / 60)}m ago`;
104
+ if (seconds < 86400) return `updated ${Math.floor(seconds / 3600)}h ago`;
105
+ return `updated ${Math.floor(seconds / 86400)}d ago`;
106
+ }
107
+ function section(parent, title, id) {
108
+ const node = el('section');
109
+ if (id) node.id = id;
110
+ if (title) node.append(el('h2', title));
111
+ parent.append(node);
112
+ return node;
113
+ }
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
+ function details(parent, title, value, key) {
125
+ const node = el('details');
126
+ node.dataset.keep = key;
127
+ const pre = el('pre', value);
128
+ pre.dataset.scroll = key + '-text';
129
+ node.append(el('summary', title), pre);
130
+ parent.append(node);
131
+ return node;
132
+ }
133
+ function table(parent, headers, rows, key) {
134
+ const wrap = el('div', undefined, 'table-scroll');
135
+ wrap.dataset.scroll = key;
136
+ wrap.tabIndex = 0;
137
+ wrap.setAttribute('role', 'region');
138
+ wrap.setAttribute('aria-label', key);
139
+ const node = el('table');
140
+ const head = el('tr');
141
+ for (const label of headers) { const th = el('th', label); th.scope = 'col'; head.append(th); }
142
+ const thead = el('thead');
143
+ thead.append(head);
144
+ const body = el('tbody');
145
+ for (const cells of rows) {
146
+ const tr = el('tr');
147
+ for (const [index, value] of cells.entries()) {
148
+ const mono = ['ID', 'Dwarf', 'QA', 'Branch'].includes(headers[index]);
149
+ const td = el('td', undefined, mono ? 'mono' : '');
150
+ td.append(value instanceof Node ? value : document.createTextNode(display(value)));
151
+ tr.append(td);
152
+ }
153
+ body.append(tr);
154
+ }
155
+ node.append(thead, body);
156
+ wrap.append(node);
157
+ parent.append(wrap);
158
+ return body;
159
+ }
160
+ // Restore disclosures, internal scroll and keyboard focus after changed data arrives.
161
+ function preserve(parent, build, { restore = true } = {}) {
162
+ const open = new Map(Array.from(parent.querySelectorAll('details[data-keep]'), n => [n.dataset.keep, n.open]));
163
+ const scroll = new Map(Array.from(parent.querySelectorAll('[data-scroll]'), n => [n.dataset.scroll, [n.scrollLeft, n.scrollTop]]));
164
+ const active = parent.contains(document.activeElement) ? document.activeElement : null;
165
+ const focusHref = active?.getAttribute('href');
166
+ const own = [parent.scrollLeft, parent.scrollTop];
167
+ parent.replaceChildren();
168
+ build();
169
+ if (!restore) return;
170
+ for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
171
+ for (const node of parent.querySelectorAll('[data-scroll]')) {
172
+ const pos = scroll.get(node.dataset.scroll);
173
+ if (pos) { node.scrollLeft = pos[0]; node.scrollTop = pos[1]; }
174
+ }
175
+ parent.scrollLeft = own[0]; parent.scrollTop = own[1];
176
+ if (focusHref) Array.from(parent.querySelectorAll('a')).find(n => n.getAttribute('href') === focusHref)?.focus({ preventScroll: true });
177
+ }
178
+ function scoped(parent, scope) {
179
+ for (const node of parent.querySelectorAll('[data-keep], [data-scroll]')) {
180
+ if (node.dataset.keep && !node.dataset.keep.startsWith(scope)) node.dataset.keep = scope + node.dataset.keep;
181
+ if (node.dataset.scroll && !node.dataset.scroll.startsWith(scope)) node.dataset.scroll = scope + node.dataset.scroll;
182
+ }
183
+ }
184
+
185
+ /* ---------- errors and the run list ---------- */
186
+ function currentResource(path) {
187
+ return path === '/api/runs' || (state.models && path === '/api/models') || (state.key != null && path === '/api/runs/' + encodeURIComponent(state.key));
188
+ }
189
+ function renderErrors() {
190
+ for (const path of state.errors.keys()) if (!currentResource(path)) state.errors.delete(path);
191
+ const banner = $('errors');
192
+ banner.textContent = Array.from(state.errors.values()).join(' · ');
193
+ banner.hidden = !state.errors.size;
194
+ }
195
+ function filteredRuns() {
196
+ return state.runs.filter(run => RM.matchRun(run, state.mapFilter, wall())).sort((a, b) => (b.updated || 0) - (a.updated || 0));
197
+ }
198
+ function renderList() {
199
+ const runs = filteredRuns();
200
+ $('run-count').textContent = `${runs.length} / ${state.runs.length}`;
201
+ if ($('filter').value !== state.mapFilter.text) $('filter').value = state.mapFilter.text;
202
+ preserve($('run-list'), () => {
203
+ if (!runs.length) {
204
+ const empty = el('div', undefined, 'empty');
205
+ empty.append(el('p', state.runs.length ? 'No runs match this filter.' : 'No runs found.'));
206
+ if (!state.runs.length) {
207
+ 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', 'Point the server at your runs:'), el('pre', 'python3 -m forge_ui --root <dir>'));
209
+ }
210
+ $('run-list').append(empty);
211
+ }
212
+ for (const run of runs) {
213
+ const item = link(undefined, run.key);
214
+ item.className = 'run-item';
215
+ item.dataset.run = run.key;
216
+ const goal = el('strong', String(run.goal || 'Untitled run').split('\n')[0], 'run-goal');
217
+ goal.title = run.goal || 'Untitled run';
218
+ const solo = run.kind === 'solo';
219
+ item.append(goal, el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'));
220
+ if (solo) item.append(el('span', 'solo', 'tag'));
221
+ const updated = el('span', relative(run.updated), 'muted updated');
222
+ if (run.updated != null) updated.dataset.epoch = run.updated;
223
+ updated.title = time(run.updated);
224
+ item.append(updated);
225
+ const counts = el('div', undefined, 'chips');
226
+ for (const [status, count] of Object.entries(run.counts || {})) {
227
+ const badge = chip(status);
228
+ badge.append(document.createTextNode(' ' + display(count)));
229
+ counts.append(badge);
230
+ }
231
+ item.append(counts);
232
+ if (!solo) {
233
+ const verification = el('span', undefined, 'verification');
234
+ verification.append(document.createTextNode('Verification '), chip(run.verification_status));
235
+ if (run.final_review) verification.append(document.createTextNode(' Review '), chip(run.final_review));
236
+ item.append(verification);
237
+ }
238
+ $('run-list').append(item);
239
+ }
240
+ });
241
+ markSelection();
242
+ }
243
+ function markSelection() {
244
+ for (const node of $('run-list').querySelectorAll('[data-run]')) {
245
+ const selected = node.dataset.run === state.key;
246
+ node.classList.toggle('selected', selected);
247
+ if (selected) node.setAttribute('aria-current', 'page'); else node.removeAttribute('aria-current');
248
+ }
249
+ }
250
+
251
+ /* ---------- the stage: one element, 3D when it can be, else the 2D views ---------- */
252
+ const stageHost = el('div', undefined, 'stage');
253
+ stageHost.id = 'stage-host';
254
+ const stageNote = el('p', undefined, 'muted stage-note');
255
+ const use3d = () => state.dim === '3d' && !state.stageFailed;
256
+ function ensureStage() {
257
+ if (!use3d() || state.stage || state.stageLoading || !window.ForgeMap3D) return;
258
+ state.stageLoading = true;
259
+ window.ForgeMap3D.load().then(mod => mod.createStage(stageHost, {
260
+ onSelect: sel => select(sel),
261
+ onOpenRun: key => { location.hash = hashFor(key, null); },
262
+ onBack: () => { location.hash = '#/'; },
263
+ onMode: mode => setMode(mode),
264
+ cloud: {
265
+ onSelect: key => { location.hash = cloudHash(key, state.cloud && state.cloud.focus); },
266
+ onFocus: pid => { location.hash = cloudHash(null, pid); },
267
+ onOpenRun: key => { location.hash = hashFor(key, null); },
268
+ onMoved: () => state.cloudUI && state.cloudUI.refreshCard(),
269
+ onLayout: name => state.cloudUI && state.cloudUI.setLayout(name),
270
+ onShape: shape => state.cloudUI && state.cloudUI.setShape(shape),
271
+ },
272
+ })).then(stage => {
273
+ state.stage = stage; state.stageLoading = false;
274
+ stage.setOverlay(state.overlay);
275
+ stage.setReplay(state.replay);
276
+ renderMain();
277
+ }, err => {
278
+ state.stageLoading = false;
279
+ if (window.ForgeMap3D.state === 'ready') window.ForgeMap3D.reason = 'stage';
280
+ state.stageFailed = window.ForgeMap3D.reason || 'load';
281
+ if (err && window.console) console.warn('forge-ui: 3D view unavailable', err);
282
+ renderMain();
283
+ });
284
+ }
285
+ function placeStage(slot) {
286
+ if (!slot) return;
287
+ const show = use3d();
288
+ if (stageHost.parentNode !== slot) slot.append(stageHost);
289
+ stageHost.hidden = !show || !state.stage;
290
+ slot.classList.toggle('stage-on', show && !!state.stage);
291
+ slot.classList.toggle('stage-loading', show && !state.stage);
292
+ stageNote.textContent = state.stageFailed && state.dim === '3d' ? window.ForgeMap3D.message() : show && !state.stage ? 'Loading the 3D view…' : '';
293
+ if (stageNote.parentNode !== slot.parentNode) slot.after(stageNote);
294
+ }
295
+
296
+ /* ---------- the map of every run ---------- */
297
+ function filterBar(parent) {
298
+ const f = state.mapFilter, opts = RM.filterOptions(state.runs);
299
+ const bar = el('form', undefined, 'filters');
300
+ bar.setAttribute('aria-label', 'Filter the runs on the map');
301
+ bar.addEventListener('submit', e => e.preventDefault());
302
+ const add = (label, control) => { const l = el('label', undefined, 'filter'); l.append(el('span', label), control); bar.append(l); return control; };
303
+ const text = el('input'); text.type = 'search'; text.value = f.text; text.placeholder = 'goal, repo, id, model…'; text.dataset.f = 'text';
304
+ add('Search', text);
305
+ const sel = (key, entries) => {
306
+ const s = el('select'); s.dataset.f = key;
307
+ 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); }
308
+ return s;
309
+ };
310
+ add('State', sel('state', [['', 'any'], ['live', 'running now'], ['failed', 'has failures'], ['done', 'all done'], ['pending', 'not started']]));
311
+ add('Repo', sel('repo', [['', 'any'], ...opts.repos.map(r => [r.path, r.name])]));
312
+ add('Kind', sel('kind', [['', 'any'], ['plan', 'plan'], ['solo', 'solo']]));
313
+ add('Model', sel('model', [['', 'any'], ...opts.models.map(m => [m, m])]));
314
+ add('Age', sel('ageDays', [[0, 'any'], [1, 'last day'], [7, 'last week'], [30, 'last month']]));
315
+ add('Review', sel('verdict', [['', 'any'], ['PASS', 'passed'], ['FAIL', 'failed'], ['none', 'no verdict']]));
316
+ const all = el('input'); all.type = 'checkbox'; all.checked = state.showAll; all.dataset.f = 'showAll';
317
+ const allL = el('label', undefined, 'filter check'); allL.append(all, document.createTextNode(' older runs too')); bar.append(allL);
318
+ 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); });
319
+ bar.append(reset);
320
+ bar.addEventListener('input', e => {
321
+ const k = e.target.dataset.f; if (!k) return;
322
+ if (k === 'showAll') state.showAll = e.target.checked;
323
+ else state.mapFilter[k] = k === 'ageDays' ? Number(e.target.value) : e.target.value;
324
+ filterChanged(false);
325
+ });
326
+ parent.append(bar);
327
+ }
328
+ function filterChanged(rebuildBar) {
329
+ store.set('map-filter', state.mapFilter);
330
+ renderList();
331
+ if (!state.key && !state.cloud) renderMap({ rebuildBar });
332
+ }
333
+ function mapCards(parent) {
334
+ const A = RM.atlasModel(state.runs, state.mapFilter, { now: wall(), showAll: state.showAll });
335
+ const wrap = el('div', undefined, 'map-cards');
336
+ for (const layer of A.layers) {
337
+ const g = el('section', undefined, 'map-repo');
338
+ g.append(el('h3', layer.name));
339
+ const grid = el('div', undefined, 'map-grid');
340
+ for (const m of A.modules.filter(x => x.layer === layer.id)) {
341
+ const r = m.run, card = link(undefined, r.key);
342
+ card.className = `map-card st-${({ live: 'running', failed: 'fail', done: 'merged', pending: 'pending' })[m.state]}${m.match ? '' : ' dim'}`;
343
+ card.append(el('strong', String(r.goal || 'Untitled run').split('\n')[0]), el('span', `${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, 'mono muted'));
344
+ const leds = el('span', undefined, 'leds');
345
+ 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); }
346
+ card.append(leds, el('span', relative(r.updated), 'muted'));
347
+ grid.append(card);
348
+ }
349
+ g.append(grid); wrap.append(g);
350
+ }
351
+ parent.append(wrap);
352
+ return A;
353
+ }
354
+ function renderMap({ rebuildBar = true } = {}) {
355
+ const main = $('main');
356
+ if (main.dataset.view !== 'map') {
357
+ main.dataset.view = 'map'; delete main.dataset.run;
358
+ main.replaceChildren();
359
+ const v = section(main, undefined, 'map-view'); v.className = 'map-view';
360
+ const head = el('div', undefined, 'map-head');
361
+ const toCloud = el('a', 'Open the run cloud', 'top-link'); toCloud.href = '#/cloud';
362
+ head.append(el('h1', 'All runs'), el('span', '', 'muted'), toCloud, viewToggle());
363
+ toCloud.classList.add('push');
364
+ v.append(head);
365
+ const bar = el('div'); bar.id = 'map-filters'; v.append(bar);
366
+ 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);
368
+ rebuildBar = true;
369
+ }
370
+ if (rebuildBar) preserve($('map-filters'), () => filterBar($('map-filters')));
371
+ syncToggles();
372
+ ensureStage();
373
+ placeStage($('stage-slot'));
374
+ let info;
375
+ if (use3d() && state.stage) {
376
+ info = state.stage.showAtlas(state.runs, state.mapFilter, { showAll: state.showAll });
377
+ $('map-cards').replaceChildren();
378
+ } else {
379
+ preserve($('map-cards'), () => { const A = mapCards($('map-cards')); info = { hidden: A.hidden, matches: A.matches, shown: A.modules.length, total: A.total }; });
380
+ }
381
+ const counter = $('map-view').querySelector('.map-head .muted');
382
+ if (info) counter.textContent = `${info.matches} of ${info.shown} shown runs match${info.hidden ? ` · ${info.hidden} older hidden` : ''}`;
383
+ }
384
+
385
+ /* ---------- the run cloud: every run in 3D, full screen ---------- */
386
+ function renderCloud() {
387
+ const main = $('main');
388
+ if (main.dataset.view !== 'cloud' || !state.cloudUI) {
389
+ main.dataset.view = 'cloud'; delete main.dataset.run;
390
+ main.replaceChildren();
391
+ const v = el('section', undefined, 'cloud-view'); v.id = 'cloud-view'; v.setAttribute('aria-label', 'Run cloud');
392
+ const slot = el('div', undefined, 'stage-slot cloud'); slot.id = 'stage-slot'; v.append(slot);
393
+ main.append(v);
394
+ state.cloudUI = window.ForgeCloudUI.create(v, {
395
+ prefs: state.cloudPrefs,
396
+ save: p => { state.cloudPrefs = p; store.set('cloud', p); },
397
+ route: ({ sel, focus }) => { location.hash = cloudHash(sel, focus); },
398
+ load3d: () => { state.dim = '3d'; store.set('view', '3d'); state.stageFailed = null; renderMain(); },
399
+ viewToggle,
400
+ });
401
+ }
402
+ ensureStage();
403
+ placeStage($('stage-slot'));
404
+ stageNote.textContent = '';
405
+ const mode = use3d() ? (state.stage ? '3d' : 'loading') : state.stageFailed && state.dim === '3d' ? 'failed' : '2d';
406
+ state.cloudUI.render({ runs: state.runs, sel: state.cloud.sel, focus: state.cloud.focus, mode, stage: mode === '3d' ? state.stage : null,
407
+ note: mode === 'failed' ? window.ForgeMap3D.message() : '' });
408
+ syncToggles();
409
+ }
410
+
411
+ /* ---------- the model leaderboard (#/models) ---------- */
412
+ function renderModels() {
413
+ const main = $('main');
414
+ if (main.dataset.view !== 'models') {
415
+ main.dataset.view = 'models'; delete main.dataset.run;
416
+ main.replaceChildren();
417
+ const v = el('section', undefined, 'models-view'); v.id = 'models-view'; main.append(v);
418
+ }
419
+ const host = $('models-view');
420
+ 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'));
422
+ return;
423
+ }
424
+ window.ForgeLeaderboard.render(host, state.modelsData, {
425
+ runs: state.runs, prefs: state.modelsPrefs, now: () => Date.now() / 1000,
426
+ savePrefs: p => { state.modelsPrefs = p; store.set('models', p); },
427
+ modelHref: alias => '#/cloud/models/' + encodeURIComponent(alias),
428
+ });
429
+ }
430
+ function loadModels() {
431
+ return request('/api/models', result => {
432
+ state.modelsError = null;
433
+ if (result.text === state.modelsText) return;
434
+ state.modelsText = result.text; state.modelsData = result.data;
435
+ if (state.models) renderModels();
436
+ }, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
437
+ }
438
+
439
+ /* ---------- a run ---------- */
440
+ function viewToggle() {
441
+ const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
442
+ for (const [v, label] of [['3d', '3D'], ['2d', '2D']]) {
443
+ const b = el('button', label); b.type = 'button'; b.dataset.dim = v;
444
+ b.addEventListener('click', () => { state.dim = v; store.set('view', v); if (v === '3d') state.stageFailed = null; renderMain(); });
445
+ g.append(b);
446
+ }
447
+ return g;
448
+ }
449
+ function syncToggles() {
450
+ for (const b of document.querySelectorAll('[data-dim]')) b.setAttribute('aria-pressed', String(b.dataset.dim === state.dim));
451
+ for (const b of document.querySelectorAll('[data-mode]')) b.setAttribute('aria-pressed', String(b.dataset.mode === state.mode));
452
+ }
453
+ function setMode(mode) {
454
+ if (mode === state.mode) return;
455
+ state.mode = mode; store.set('mode', mode);
456
+ if (mode !== 'files') { state.stageMode = mode; if (state.stage) state.stage.setMode(mode); }
457
+ syncToggles();
458
+ const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
459
+ }
460
+ function toolbar(parent) {
461
+ const bar = el('div', undefined, 'run-toolbar'); bar.setAttribute('role', 'toolbar'); bar.setAttribute('aria-label', 'Run view');
462
+ 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', 'Planned waves', '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
+ ['files', 'Files', 'Which tasks planned, changed, drifted into or conflicted on which files']].filter(([m]) => m !== 'files' || window.ForgeFiles)) {
465
+ const b = el('button', label); b.type = 'button'; b.dataset.mode = m; b.title = title; b.addEventListener('click', () => setMode(m)); modes.append(b);
466
+ }
467
+ bar.append(modes, viewToggle());
468
+ const ov = el('div', undefined, 'overlays');
469
+ const sel = (label, key, entries) => {
470
+ const l = el('label', undefined, 'filter'); l.append(el('span', label));
471
+ const s = el('select'); s.dataset.ov = key;
472
+ 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); }
473
+ l.append(s); ov.append(l);
474
+ };
475
+ sel('Height', 'height', [['none', 'difficulty'], ['duration', 'duration'], ['tokens', 'tokens'], ['attempts', 'attempts']]);
476
+ sel('Colour', 'color', [['status', 'status'], ['model', 'model'], ['duration', 'duration'], ['tokens', 'tokens']]);
477
+ for (const [key, label, title] of [['critical', 'Critical path', 'The chain of dependencies that bounded the wall time'], ['arcs', 'Review marks', 'Shadow disagreements and Clef rings'], ['ghosts', 'Projected', 'Where the tasks that have not started would run, by forge-schedule\'s rules']]) {
478
+ const l = el('label', undefined, 'filter check'); l.title = title;
479
+ const c = el('input'); c.type = 'checkbox'; c.checked = !!state.overlay[key]; c.dataset.ov = key;
480
+ l.append(c, document.createTextNode(' ' + label)); ov.append(l);
481
+ }
482
+ ov.addEventListener('input', e => {
483
+ const k = e.target.dataset.ov; if (!k) return;
484
+ state.overlay[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
485
+ store.set('overlay', state.overlay);
486
+ if (state.stage) state.stage.setOverlay({ [k]: state.overlay[k] });
487
+ const run = state.cache.get(state.key)?.data; if (run) renderRunBody(run);
488
+ });
489
+ bar.append(ov);
490
+ const rp = el('div', undefined, 'replay'); rp.id = 'replay';
491
+ const play = el('button', '▶ Replay'); play.type = 'button'; play.dataset.act = 'play';
492
+ 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
+ const speed = el('select'); speed.dataset.act = 'speed'; speed.setAttribute('aria-label', 'Replay speed');
494
+ 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', 'Live'); live.type = 'button'; live.dataset.act = 'live';
496
+ const at = el('span', '', 'muted mono'); at.dataset.act = 'at';
497
+ 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); });
499
+ rp.addEventListener('input', e => {
500
+ const a = e.target.dataset.act, run = state.cache.get(state.key)?.data; if (!run) return;
501
+ 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
+ if (a === 'speed') { state.speed = Number(e.target.value); store.set('speed', state.speed); }
503
+ });
504
+ bar.append(rp);
505
+ parent.append(bar);
506
+ }
507
+ function syncReplay(run) {
508
+ const rp = $('replay'); if (!rp) return;
509
+ const r = RM.timeRange(run, now());
510
+ rp.hidden = !r;
511
+ if (!r) return;
512
+ const range = rp.querySelector('[data-act=scrub]'), at = rp.querySelector('[data-act=at]');
513
+ 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 ? 'live' : `+${RM.fmtDur(state.replay - r.start)} · ${new Date(state.replay * 1000).toLocaleTimeString()}`;
515
+ rp.querySelector('[data-act=play]').textContent = state.playing ? '❚❚ Pause' : '▶ Replay';
516
+ rp.querySelector('[data-act=live]').disabled = state.replay === null;
517
+ }
518
+ function setReplay(t) {
519
+ state.replay = t === null ? null : t;
520
+ if (t === null) stopPlay();
521
+ if (state.stage) state.stage.setReplay(state.replay);
522
+ const run = state.cache.get(state.key)?.data; if (run) { renderRunBody(run); }
523
+ }
524
+ function stopPlay() { if (state.playing) { clearInterval(state.playing); state.playing = null; } }
525
+ function togglePlay() {
526
+ const run = state.cache.get(state.key)?.data; if (!run) return;
527
+ if (state.playing) { stopPlay(); syncReplay(run); return; }
528
+ const r = RM.timeRange(run, now()); if (!r) return;
529
+ if (state.replay === null || state.replay >= r.end) state.replay = r.start;
530
+ state.playing = setInterval(() => {
531
+ const run2 = state.cache.get(state.key)?.data, r2 = run2 && RM.timeRange(run2, now());
532
+ if (!r2) { stopPlay(); return; }
533
+ const t = Math.min(r2.end, state.replay + state.speed * .2);
534
+ setReplay(t);
535
+ if (t >= r2.end) { stopPlay(); syncReplay(run2); }
536
+ }, 200);
537
+ syncReplay(run);
538
+ }
539
+ // A copy of the run whose statuses are what they were at time t (for the 2D views during a replay).
540
+ function atReplay(run) {
541
+ if (state.replay === null) return run;
542
+ const s = RM.stateAt(run, state.replay);
543
+ return { ...run, tasks: list(run.tasks).map(t => ({ ...t, status: s.tasks[t.id] ? s.tasks[t.id].status : t.status })) };
544
+ }
545
+ function renderHeader(run) {
546
+ const head = $('run-head');
547
+ preserve(head, () => {
548
+ const solo = run.kind === 'solo';
549
+ head.append(el('h2', String(run.goal || 'Untitled run').split('\n')[0]));
550
+ const meta = el('p', undefined, 'run-meta');
551
+ meta.append(el('span', RM.basename(run.repo), 'repo'), el('span', run.run_id, 'mono'), el('span', solo ? 'solo run' : `${run.task_count} tasks · ${list(run.waves).length} waves`),
552
+ el('span', relative(run.updated), 'muted'));
553
+ const counts = el('span', undefined, 'chips');
554
+ for (const [s, n] of Object.entries(run.counts || {})) { const c = chip(s); c.append(document.createTextNode(' ' + n)); counts.append(c); }
555
+ meta.append(counts);
556
+ if (!SNAP) {
557
+ const ex = el('button', 'Export…', 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
558
+ ex.addEventListener('click', () => exportDialog(run)); meta.append(ex);
559
+ }
560
+ head.append(meta);
561
+ const more = el('details', undefined, 'run-more'); more.dataset.keep = 'run-more';
562
+ more.append(el('summary', 'Run details'));
563
+ fields(more, [['Repo', run.repo, true], ['Run ID', run.run_id, true], ['Path', run.path, true], ['Source', run.source],
564
+ ['Decompose', run.decompose_level], ['Updated', time(run.updated)], ['Explore', run.settings?.explore], ['Shadow rate', run.settings?.shadow_rate],
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);
572
+ if (list(run.warnings).length) details(head, `Reader warnings (${run.warnings.length})`, run.warnings.join('\n'), 'warnings');
573
+ if (list(run.truncated).length) details(head, 'Truncated fields', run.truncated.join('\n'), 'truncated');
574
+ scoped(head, run.key + ':');
575
+ });
576
+ }
577
+ function renderTable(run) {
578
+ const host = $('run-tasks');
579
+ preserve(host, () => {
580
+ if (run.kind === 'solo') return;
581
+ host.append(el('h2', `Tasks (${list(run.tasks).length})`));
582
+ if (!list(run.tasks).length) { host.append(el('p', 'This run has no tasks yet.', 'empty')); return; }
583
+ const sev = f => f && f.worst ? `${f.worst} ×${f[f.worst]}` : f && f.plausible ? `~${f.plausible}` : '—';
584
+ const rows = list(run.tasks).map(task => {
585
+ const picks = list(task.route).filter(r => r.explored);
586
+ const exploration = task.explored ? '* ' + (picks.length ? picks.map(r => `${display(r.chosen)} p=${display(r.propensity)}`).join('; ') : 'p=—') : '—';
587
+ const deps = el('span', undefined, 'mono');
588
+ list(task.deps).forEach((id, i) => { if (i) deps.append(document.createTextNode(', ')); deps.append(link(id, run.key, { kind: 'task', id })); });
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
+ });
600
+ });
601
+ scoped(host, run.key + ':');
602
+ });
603
+ }
604
+ function renderInspector(run) {
605
+ const host = $('inspector'); if (!host || !window.ForgePanel) return;
606
+ const sel = state.sel || (run.kind === 'solo' ? { kind: 'task', id: list(run.tasks)[0]?.id } : null);
607
+ preserve(host, () => {
608
+ window.ForgePanel.render(host, {
609
+ run, sel, now: now(),
610
+ fetchJSON: path => fetchJSON(path).then(r => r.data),
611
+ rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderInspector(r); },
612
+ hash: s => hashFor(run.key, s), onSelect: s => select(s), diffHash: spec => diffHash(run.key, spec),
613
+ onFractal: (id, data) => { if (state.stage && state.stage.setFractal) state.stage.setFractal(id, data); },
614
+ });
615
+ scoped(host, run.key + ':');
616
+ });
617
+ }
618
+ function render2d(run) {
619
+ const host = $('graph2d'); if (!host) return;
620
+ const show2d = !use3d() || !state.stage;
621
+ host.hidden = !show2d;
622
+ if (!show2d) { host.replaceChildren(); return; }
623
+ preserve(host, () => {
624
+ const r = atReplay(run);
625
+ try {
626
+ 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 });
627
+ else window.ForgeGraph.render(host, r, { selected: state.sel && (state.sel.task || state.sel.id), onSelect: id => select({ kind: 'task', id }) });
628
+ } catch (_) { host.replaceChildren(el('p', 'Graph unavailable. Select tasks in the table below.', 'muted')); }
629
+ });
630
+ }
631
+ function renderStrip(run) {
632
+ const host = $('strip'); if (!host) return;
633
+ const show = state.mode === 'exec' && run.kind !== 'solo';
634
+ host.hidden = !show;
635
+ if (show) window.ForgeGantt.strip(host, run, { now: now(), replay: state.replay }); else host.replaceChildren();
636
+ }
637
+ // Files: which task touched which file, in the stage's place (the inspector stays beside it)
638
+ function renderFiles(run) {
639
+ const host = $('files-view'), view = $('run-view'); if (!host || !view) return;
640
+ const open = state.mode === 'files' && !!window.ForgeFiles;
641
+ view.classList.toggle('files-open', open); host.hidden = !open;
642
+ if (!open) { host.filesSig = ''; return; }
643
+ const c = state.files.get(run.key);
644
+ if (!c || c.updated !== run.updated) loadFiles(run);
645
+ if (!c || !c.data) {
646
+ host.filesSig = '';
647
+ 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'));
648
+ return;
649
+ }
650
+ const sig = JSON.stringify([c.at, state.sel]);
651
+ if (host.filesSig === sig) return; // a poll that changed nothing here redraws nothing
652
+ host.filesSig = sig;
653
+ window.ForgeFiles.render(host, c.data, { run, sel: state.sel, onSelectTask: id => select({ kind: 'task', id }),
654
+ diffHref: (task, path) => diffHash(run.key, { kind: 'task', task, source: 'current', file: path }),
655
+ prefs: state.filesPrefs, savePrefs: p => { state.filesPrefs = p; store.set('files', p); } });
656
+ }
657
+ function loadFiles(run) {
658
+ const key = run.key, updated = run.updated, c = state.files.get(key) || {};
659
+ if (c.pending === updated) return;
660
+ c.pending = updated; state.files.set(key, c);
661
+ 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
+ .finally(() => { c.updated = updated; c.pending = null; const cur = state.cache.get(state.key)?.data; if (cur && cur.key === key) renderFiles(cur); });
663
+ }
664
+ // the diff viewer takes the stage's place while it is open
665
+ function renderDiff(run) {
666
+ const host = $('diff-view'), view = $('run-view'); if (!host || !view) return;
667
+ const open = !!state.diff && !!window.ForgeDiff;
668
+ view.classList.toggle('diff-open', open); host.hidden = !open;
669
+ if (!open) { host.replaceChildren(); host.diffSig = ''; return; }
670
+ window.ForgeDiff.render(host, { run, spec: state.diff, fetchJSON: path => fetchJSON(path).then(r => r.data),
671
+ rerender: () => { const r = state.cache.get(state.key)?.data; if (r) renderDiff(r); },
672
+ href: spec => diffHash(run.key, spec), closeHref: hashFor(run.key, state.sel) });
673
+ }
674
+ function renderRunBody(run) {
675
+ renderDiff(run);
676
+ syncToggles();
677
+ syncReplay(run);
678
+ placeStage($('stage-slot'));
679
+ if (use3d() && state.stage) state.stage.showRun(run, { mode: state.stageMode, sel: state.sel });
680
+ render2d(run);
681
+ renderStrip(run);
682
+ renderFiles(run);
683
+ renderInspector(run);
684
+ }
685
+ function renderRun(run) {
686
+ const main = $('main');
687
+ if (main.dataset.view !== 'run' || main.dataset.run !== run.key) {
688
+ stopPlay(); state.replay = null;
689
+ if (state.stage) state.stage.setReplay(null);
690
+ main.dataset.view = 'run'; main.dataset.run = run.key;
691
+ main.replaceChildren();
692
+ const head = el('section', undefined, 'run-header'); head.id = 'run-head'; main.append(head);
693
+ const view = el('section', undefined, 'run-view'); view.id = 'run-view'; main.append(view);
694
+ toolbar(view);
695
+ const row = el('div', undefined, 'stage-row');
696
+ const col = el('div', undefined, 'stage-col');
697
+ const slot = el('div', undefined, 'stage-slot'); slot.id = 'stage-slot';
698
+ const g2 = el('div', undefined, 'graph'); g2.id = 'graph2d'; g2.dataset.scroll = 'graph';
699
+ const files = el('div', undefined, 'files-view'); files.id = 'files-view'; files.hidden = true;
700
+ col.append(slot, g2, files);
701
+ const insp = el('aside', undefined, 'inspector'); insp.id = 'inspector'; insp.setAttribute('aria-label', 'Inspector'); insp.tabIndex = -1;
702
+ row.append(col, insp); view.append(row);
703
+ 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 tasks = el('section', undefined, 'run-tasks'); tasks.id = 'run-tasks'; main.append(tasks);
706
+ window.scrollTo(0, 0);
707
+ }
708
+ ensureStage();
709
+ renderHeader(run);
710
+ renderRunBody(run);
711
+ renderTable(run);
712
+ announce(run);
713
+ }
714
+ function renderMain() {
715
+ document.body.classList.toggle('cloud-mode', !!state.cloud);
716
+ if (state.cloud) { renderCloud(); return; }
717
+ if (state.models) { renderModels(); return; }
718
+ if (!state.key) { renderMap(); return; }
719
+ const cached = state.cache.get(state.key);
720
+ if (cached) renderRun(cached.data);
721
+ }
722
+ // A polite summary of what changed since the last poll, for screen readers.
723
+ function announce(run) {
724
+ const prev = state.lastStatuses.get(run.key), cur = new Map(list(run.tasks).map(t => [t.id, t.status]));
725
+ state.lastStatuses.set(run.key, cur);
726
+ if (!prev || Date.now() - state.announced < 10000) return;
727
+ const changes = [...cur].filter(([id, s]) => prev.has(id) && prev.get(id) !== s).map(([id, s]) => `${id} ${prev.get(id).toLowerCase()} → ${s.toLowerCase()}`);
728
+ if (!changes.length) return;
729
+ state.announced = Date.now();
730
+ $('live').textContent = changes.slice(0, 5).join('; ');
731
+ }
732
+
733
+ /* ---------- data ---------- */
734
+ async function fetchJSON(path) {
735
+ if (SNAP) {
736
+ const data = SNAP.api[path];
737
+ if (data === undefined) { const error = new Error('not in this export'); error.status = 404; error.snapshot = true; throw error; }
738
+ return { text: JSON.stringify(data), data };
739
+ }
740
+ const response = await fetch(path, { cache: 'no-store' });
741
+ if (!response.ok) {
742
+ const error = new Error(`HTTP ${response.status}`);
743
+ error.status = response.status;
744
+ throw error;
745
+ }
746
+ const text = await response.text();
747
+ return { text, data: JSON.parse(text) };
748
+ }
749
+ // Coalesce polling and navigation requests for the same resource.
750
+ function request(path, consume, label, onError) {
751
+ if (state.pending.has(path)) return state.pending.get(path);
752
+ const pending = fetchJSON(path).then(result => {
753
+ consume(result);
754
+ state.errors.delete(path);
755
+ }).catch(error => {
756
+ if (currentResource(path)) {
757
+ state.errors.set(path, `${label}: ${error.message}. Any last good data is kept. Refresh retries while enabled and this tab is visible.`);
758
+ onError?.(error);
759
+ }
760
+ }).finally(() => { state.pending.delete(path); renderErrors(); });
761
+ state.pending.set(path, pending);
762
+ return pending;
763
+ }
764
+ function loadList() {
765
+ return request('/api/runs', result => {
766
+ if (result.text === state.listText) return;
767
+ state.runs = list(result.data.runs);
768
+ state.listText = result.text;
769
+ if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
770
+ renderList();
771
+ if (state.cloud) renderCloud();
772
+ else if (!state.key) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
773
+ }, 'Could not refresh runs');
774
+ }
775
+ function loadRun(key) {
776
+ return request('/api/runs/' + encodeURIComponent(key), result => {
777
+ const changed = state.cache.get(key)?.text !== result.text;
778
+ state.cache.set(key, result);
779
+ if (state.key === key && changed) renderRun(result.data);
780
+ }, 'Could not refresh run', error => {
781
+ if (state.key !== key || state.cache.has(key)) return;
782
+ if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
783
+ $('main').dataset.view = 'waiting';
784
+ $('main').replaceChildren(el('p', error.status === 404 ?
785
+ 'Waiting for this run to appear… checking again while this tab is visible. If its directory was removed, pick another run.' :
786
+ 'Could not load this run. Select another run, or leave refresh enabled to retry.', 'empty'));
787
+ });
788
+ }
789
+ function navigate() {
790
+ let parsed = { key: null, sel: null };
791
+ try { parsed = parseHash(location.hash); } catch (_) { /* invalid escapes fall back to the map */ }
792
+ if (SNAP && parsed.key !== SNAP.meta.key) { // an export shows its one run
793
+ history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
794
+ parsed = parseHash(location.hash);
795
+ }
796
+ const keyChanged = parsed.key !== state.key;
797
+ state.key = parsed.key;
798
+ state.sel = parsed.sel;
799
+ const diffWas = JSON.stringify(state.diff || null);
800
+ state.diff = parsed.diff || null;
801
+ state.cloud = parsed.cloud || null;
802
+ state.models = !!parsed.models;
803
+ if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
804
+ cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
805
+ history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
806
+ }
807
+ document.body.classList.toggle('cloud-mode', !!state.cloud);
808
+ for (const a of document.querySelectorAll('.topbar .top-link')) {
809
+ const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
810
+ if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
811
+ }
812
+ renderErrors();
813
+ markSelection();
814
+ if (state.cloud) { renderCloud(); return; }
815
+ if (state.models) { renderModels(); loadModels(); return; }
816
+ if (!state.key) { renderMap(); return; }
817
+ const cached = state.cache.get(state.key);
818
+ if (cached) {
819
+ if (keyChanged) renderRun(cached.data);
820
+ else { renderRunBody(cached.data); renderTable(cached.data); }
821
+ } else { $('main').dataset.view = 'loading'; $('main').replaceChildren(el('p', 'Loading run…', 'empty')); }
822
+ loadRun(state.key);
823
+ // 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 = diffWas === 'null' || JSON.stringify({ ...JSON.parse(diffWas), file: null }) !== JSON.stringify({ ...state.diff, file: null });
826
+ if (opened && $('diff-view')) { $('run-view').scrollIntoView({ block: 'start' }); $('diff-view').focus({ preventScroll: true }); }
827
+ } else if (state.sel && !keyChanged && !stageHost.contains(document.activeElement)) $('inspector')?.focus({ preventScroll: true });
828
+ }
829
+ function busy() {
830
+ if (state.key) {
831
+ const run = state.cache.get(state.key)?.data;
832
+ return !!run && (list(run.tasks).some(t => t.status === 'RUNNING') || (run.final_review && run.final_review.state === 'running'));
833
+ }
834
+ return state.runs.some(r => r.counts && r.counts.RUNNING);
835
+ }
836
+ const background = () => document.visibilityState !== 'visible' && !!window.ForgeNotify && window.ForgeNotify.background();
837
+ function refresh() {
838
+ if (state.paused || SNAP) return;
839
+ if (document.visibilityState !== 'visible') { if (background()) loadList(); return; } // only to notify
840
+ loadList();
841
+ if (state.key) loadRun(state.key);
842
+ if (state.models) loadModels();
843
+ for (const node of document.querySelectorAll('[data-epoch]')) node.textContent = relative(node.dataset.epoch);
844
+ }
845
+ // Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
846
+ function schedule() {
847
+ clearTimeout(state.timer);
848
+ if (SNAP) return; // an export never changes
849
+ state.timer = setTimeout(() => { refresh(); schedule(); }, background() ? 30000 : busy() ? 2000 : 5000);
850
+ }
851
+ // the quick jump's commands: what the page can do from anywhere
852
+ function commands() {
853
+ const run = state.key && state.cache.get(state.key)?.data;
854
+ const out = [
855
+ { id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
856
+ { id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
857
+ { id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
858
+ { id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
859
+ { 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
+ action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
861
+ { id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
862
+ action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
863
+ ];
864
+ 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
+ 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
+ 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') });
867
+ 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') });
868
+ return out;
869
+ }
870
+ function cloudFilter(filter) {
871
+ const f = window.ForgeCloudModel.normFilter(filter);
872
+ state.cloudPrefs = { ...state.cloudPrefs, filter: f }; store.set('cloud', state.cloudPrefs);
873
+ if (state.cloud && state.cloudUI) state.cloudUI.setFilter(f);
874
+ if (!state.cloud) location.hash = '#/cloud';
875
+ }
876
+ // what an export holds: chosen each time, private texts and paths off by default
877
+ let exportDlg = null;
878
+ function exportDialog(run) {
879
+ if (!exportDlg) {
880
+ exportDlg = el('dialog', undefined, 'export-dialog'); exportDlg.setAttribute('aria-label', 'Export this run');
881
+ document.body.append(exportDlg);
882
+ exportDlg.addEventListener('click', e => { if (e.target === exportDlg) exportDlg.close(); });
883
+ }
884
+ const d = exportDlg; d.replaceChildren();
885
+ d.append(el('h2', 'Export this run as one HTML file'),
886
+ 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'));
887
+ 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; };
888
+ const texts = opt('Include prompts, diffs and reports', 'Prompts, reviewer reports, logs, finding details and diffs can contain code, secrets and private notes.', false);
889
+ const hide = opt('Hide local paths', 'Paths on this machine are reduced to their last folder or file name.', true);
890
+ const note = el('p', '', 'warn export-note');
891
+ const go = el('a', 'Download', 'button primary'); go.setAttribute('download', '');
892
+ const sync = () => {
893
+ go.href = `/api/runs/${encodeURIComponent(run.key)}/export?texts=${texts.checked ? 1 : 0}&paths=${hide.checked ? 0 : 1}`;
894
+ note.textContent = texts.checked ? 'Share this file only with people who may read this repository\'s code.' : '';
895
+ };
896
+ texts.addEventListener('change', sync); hide.addEventListener('change', sync); sync();
897
+ const cancel = el('button', 'Cancel'); cancel.type = 'button'; cancel.addEventListener('click', () => d.close());
898
+ go.addEventListener('click', () => setTimeout(() => d.close(), 300));
899
+ const row = el('div', undefined, 'export-actions'); row.append(cancel, go);
900
+ d.append(note, row);
901
+ d.showModal(); go.focus();
902
+ }
903
+ function snapshotBanner() {
904
+ const m = SNAP.meta, b = el('div', undefined, 'snapshot-banner'); b.setAttribute('role', 'note');
905
+ const when = Number.isFinite(m.generated) ? new Date(m.generated * 1000).toLocaleString() : 'an unknown date';
906
+ 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` : ''}`));
907
+ document.querySelector('.topbar').after(b);
908
+ }
909
+ function init() {
910
+ if (SNAP) { document.body.classList.add('snapshot'); snapshotBanner(); $('refresh').checked = false; }
911
+ if (window.ForgeSearch) window.ForgeSearch.init({ runs: () => state.runs, commands, go: href => { location.hash = href; }, cloudFilter });
912
+ if (window.ForgeNotify) window.ForgeNotify.init({ go: href => { location.hash = href; } });
913
+ $('filter').addEventListener('input', event => { state.mapFilter.text = event.target.value; filterChanged(true); });
914
+ $('refresh').addEventListener('change', event => { state.paused = !event.target.checked; refresh(); });
915
+ window.addEventListener('hashchange', navigate);
916
+ document.addEventListener('visibilitychange', refresh);
917
+ 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
+ loadList();
919
+ navigate();
920
+ schedule();
921
+ }
922
+ init();
923
+ return { refresh, state };
924
+ })();