@giamme/forge 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. package/ui/forge_ui/static/uikit.js +161 -0
@@ -1,26 +1,38 @@
1
- """Local HTTP server: the JSON API of docs/api.md and the static page. Read-only."""
1
+ """Local HTTP server: the JSON API of docs/api.md and the static page.
2
+
3
+ Reads are open to the local page. Writes (POST) pass the checks of `_write_response` and then run one
4
+ forge CLI call through control.py; the server itself never writes a run dir, a repo or config.
5
+ """
2
6
  from __future__ import annotations
3
7
 
4
8
  import base64
5
9
  from contextlib import contextmanager
6
10
  import hashlib
11
+ import hmac
7
12
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
13
  import json
9
14
  import os
10
15
  from pathlib import Path
11
16
  import re
17
+ import secrets
12
18
  import sys
13
19
  import threading
14
20
  import time
15
21
  import urllib.parse
16
22
 
17
- from forge_ui import __version__, build_id
23
+ from forge_ui import __version__, build_id, control
18
24
 
19
25
 
20
26
  STATIC_DIR = Path(__file__).resolve().parent / 'static'
21
27
  # The build of the code this process loaded. Files edited later must not change it: the launcher
22
28
  # compares /api/health with its own build_id() to find a server still running old code.
23
29
  BUILD = build_id()
30
+ # The forge command every write calls. A test points it at a fake that records its argv.
31
+ forge_cli = Path(__file__).resolve().parents[2] / 'forge'
32
+ MAX_BODY = 64 * 1024
33
+ DRAIN_WAIT = 1.0
34
+ DRAIN_MAX = 1024 * 1024 # an unread request body is read (to here) before a refusal, so the client sees the answer
35
+ TOKEN_HEADER = 'X-Forge-UI-Token'
24
36
 
25
37
  _RUN_PATH = re.compile(r'/api/runs/([0-9a-f]{12})')
26
38
  _TASK_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/tasks/([^/]+)')
@@ -50,6 +62,8 @@ _CSP = ("default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self'
50
62
  _PAGE_CSP = ("default-src 'self'; style-src 'self'; script-src 'self' https://cdn.jsdelivr.net/npm/three@0.160.0/{}; "
51
63
  "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'")
52
64
  _IMPORTMAP = re.compile(r'<script type="importmap">(.*?)</script>', re.DOTALL | re.IGNORECASE)
65
+ _HEAD_TAG = re.compile(rb'<head(?:\s[^>]*)?>', re.IGNORECASE)
66
+ _DOCTYPE = re.compile(rb'<!doctype[^>]*>', re.IGNORECASE)
53
67
 
54
68
 
55
69
  def _log(message):
@@ -65,6 +79,15 @@ def page_csp(body):
65
79
  return _PAGE_CSP.format(hashes)
66
80
 
67
81
 
82
+ def with_token(body, token):
83
+ """index.html with `<meta name="forge-ui-token">` as the first thing in its <head>."""
84
+ meta = b'<meta name="forge-ui-token" content="%s">' % token.encode('ascii')
85
+ # Without a <head> tag the meta goes after the doctype (before it the page would render in quirks mode).
86
+ found = _HEAD_TAG.search(body) or _DOCTYPE.search(body)
87
+ end = found.end() if found else 0
88
+ return body[:end] + meta + body[end:]
89
+
90
+
68
91
  def _task_id(segment):
69
92
  """A task id from its URL segment, or None when it cannot name a task dir."""
70
93
  try:
@@ -140,6 +163,14 @@ class Handler(BaseHTTPRequestHandler):
140
163
  def do_HEAD(self):
141
164
  self._serve(head=True)
142
165
 
166
+ def do_POST(self):
167
+ with self._active():
168
+ self._send(*self._write_response())
169
+
170
+ def do_OPTIONS(self):
171
+ # No CORS: a preflight is never answered, so no other origin can learn that a write is allowed.
172
+ self._method_not_allowed()
173
+
143
174
  def __getattr__(self, name):
144
175
  # Every other method (POST, PUT, a made-up verb, ...) gets a 405, not the 501 of the base class.
145
176
  if name.startswith('do_'):
@@ -189,7 +220,10 @@ class Handler(BaseHTTPRequestHandler):
189
220
  if not self._host_allowed():
190
221
  self._send(*self._json(403, {'error': 'forbidden'}))
191
222
  return
192
- self._send(*self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'}))
223
+ allow = 'GET, HEAD'
224
+ if not self.server.read_only and control.has_route('POST', self.path.partition('?')[0]):
225
+ allow += ', POST'
226
+ self._send(*self._json(405, {'error': 'method not allowed'}, {'Allow': allow}))
193
227
 
194
228
  @contextmanager
195
229
  def _active(self):
@@ -231,6 +265,7 @@ class Handler(BaseHTTPRequestHandler):
231
265
  'version': __version__,
232
266
  'build': BUILD,
233
267
  'pid': os.getpid(),
268
+ 'controls': not self.server.read_only,
234
269
  })
235
270
  catalog = self.server.catalog
236
271
  match = _EXPORT_PATH.fullmatch(path)
@@ -239,6 +274,9 @@ class Handler(BaseHTTPRequestHandler):
239
274
  value = api_get(catalog, path)
240
275
  if value is not NO_ROUTE:
241
276
  return self._found(value)
277
+ routed = control.dispatch(self.server, forge_cli, 'GET', path, None, query=self.query)
278
+ if routed:
279
+ return self._json(routed[0], routed[1])
242
280
  if path == '/':
243
281
  return self._static('index.html')
244
282
  match = _STATIC_PATH.fullmatch(path)
@@ -260,8 +298,88 @@ class Handler(BaseHTTPRequestHandler):
260
298
  if body is None:
261
299
  return self._json(404, {'error': 'not found'})
262
300
  kind = name.rsplit('.', 1)[1]
301
+ if name == 'index.html' and not self.server.read_only:
302
+ body = with_token(body, self.server.token)
263
303
  return 200, _TYPES[kind], body, {'Content-Security-Policy': page_csp(body)} if kind == 'html' else {}
264
304
 
305
+ # ------------------------------------------------------------------------------ writes
306
+
307
+ def _drain(self):
308
+ """Read (and drop) the request body that is already on its way, up to DRAIN_MAX, so a refusal is not
309
+ lost to a connection reset. A client that claims more than it sends costs DRAIN_WAIT, not the timeout."""
310
+ self.close_connection = True
311
+ try:
312
+ left = min(int(self.headers.get('Content-Length') or 0), DRAIN_MAX)
313
+ self.connection.settimeout(DRAIN_WAIT)
314
+ while left > 0:
315
+ chunk = self.rfile.read1(min(left, 65536))
316
+ if not chunk:
317
+ break
318
+ left -= len(chunk)
319
+ except (OSError, ValueError):
320
+ pass
321
+
322
+ def _refuse(self, status, error):
323
+ self._drain()
324
+ return self._json(status, {'error': error})
325
+
326
+ def _body(self):
327
+ """(object, None) for a valid JSON object body, else (None, refusal response)."""
328
+ if self.headers.get('Transfer-Encoding') is not None:
329
+ return None, self._refuse(400, 'bad request')
330
+ length = (self.headers.get('Content-Length') or '').strip()
331
+ if not length.isascii() or not length.isdigit():
332
+ return None, self._refuse(400, 'bad request')
333
+ if int(length) > MAX_BODY:
334
+ return None, self._refuse(413, 'payload too large')
335
+ try:
336
+ raw = self.rfile.read(int(length))
337
+ if len(raw) != int(length):
338
+ raise ValueError('short body')
339
+ body = json.loads(raw.decode('utf-8'))
340
+ except (OSError, ValueError, RecursionError): # not UTF-8, not JSON, or the client hung up
341
+ self.close_connection = True
342
+ return None, self._json(400, {'error': 'bad request'})
343
+ if not isinstance(body, dict):
344
+ return None, self._json(400, {'error': 'bad request'})
345
+ return body, None
346
+
347
+ def _write_response(self):
348
+ """The answer to a POST. A request passes every check, in this order, before any route sees it:
349
+ the Host, read-only mode (405), the token, the content type, the Origin and the body."""
350
+ server = self.server
351
+ path = self.path.partition('?')[0]
352
+ if not self._host_allowed():
353
+ return self._refuse(403, 'forbidden')
354
+ if server.read_only:
355
+ self._drain()
356
+ return self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'})
357
+ # compare_digest takes bytes here: a non-ASCII header value must not raise.
358
+ sent = (self.headers.get(TOKEN_HEADER) or '').encode('utf-8', 'surrogateescape')
359
+ if not hmac.compare_digest(sent, server.token.encode('ascii')):
360
+ return self._refuse(403, 'forbidden')
361
+ kind = (self.headers.get('Content-Type') or '').partition(';')[0].strip().lower()
362
+ if kind != 'application/json':
363
+ return self._refuse(400, 'content type must be application/json')
364
+ origin = self.headers.get('Origin')
365
+ if origin is not None and origin != 'http://' + (self.headers.get('Host') or ''):
366
+ return self._refuse(403, 'forbidden')
367
+ body, refusal = self._body()
368
+ if refusal:
369
+ return refusal
370
+ try:
371
+ routed = control.dispatch(server, forge_cli, 'POST', path, body)
372
+ except Exception as exc: # one bad request must not kill the server
373
+ detail = ' '.join(str(exc).split())
374
+ _log(f'internal error on POST {path}: {type(exc).__name__}: {detail}')
375
+ return self._json(500, {'error': 'internal error'})
376
+ if routed is None:
377
+ return self._json(405, {'error': 'method not allowed'}, {'Allow': 'GET, HEAD'})
378
+ status, obj, route, key = routed
379
+ if not self._quiet():
380
+ _log(f'control {route} {key} -> {status}')
381
+ return self._json(status, obj)
382
+
265
383
  def _export(self, catalog, key):
266
384
  # One run as one HTML file, to download: never rendered from this origin (sandbox, no sources).
267
385
  from forge_ui import export
@@ -301,9 +419,12 @@ class Handler(BaseHTTPRequestHandler):
301
419
  pass # the client hung up (BrokenPipeError, ConnectionResetError, ...): nothing to tell it
302
420
 
303
421
 
304
- def make_server(catalog, host='127.0.0.1', port=0, static_dir=None, quiet=False):
422
+ def make_server(catalog, host='127.0.0.1', port=0, static_dir=None, quiet=False, read_only=False):
305
423
  server = ThreadingHTTPServer((host, port), Handler)
306
424
  server.catalog = catalog
425
+ server.read_only = read_only
426
+ server.token = secrets.token_urlsafe(32) # per process; only the page this process serves carries it
427
+ server.run_locks = control.RunLocks()
307
428
  server.static_dir = STATIC_DIR if static_dir is None else static_dir
308
429
  server.quiet = quiet
309
430
  server.last_request = time.monotonic() # stamped when a request arrives and when it ends
@@ -1,8 +1,8 @@
1
- /* forge-ui: read-only API viewer. All API values enter the DOM as text.
1
+ /* forge-ui: the page. It reads the API; a change to a run goes through the write helpers in uikit.js (see controls.js). All API values enter the DOM as text.
2
2
  Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
3
3
  run (#/runs/<key>) with its stage (3D, or the 2D graph and chart), the inspector beside it and the task table under it. The stage is one element that moves between views,
4
4
  so the 3D scene (and its WebGL context) survives navigation and refreshes. */
5
- window.ForgeUI = (() => {
5
+ window.ForgeApp = (() => {
6
6
  'use strict';
7
7
  const RM = window.ForgeRunModel;
8
8
  // an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
@@ -18,8 +18,9 @@ window.ForgeUI = (() => {
18
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
19
  replay: null, playing: null, speed: store.get('speed', 60), stage: null, stageFailed: null, stageLoading: false, timer: null,
20
20
  lastStatuses: new Map(), announced: 0, rendered: null, cloud: null, cloudUI: null, cloudPrefs: store.get('cloud', {}),
21
+ settings: false, lastKey: null, settingsRun: null, settingsPickedAt: null, settingsView: null,
21
22
  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
+ files: new Map(), findingsCount: new Map(), filesPrefs: store.get('files', {}), runSort: store.get('run-sort', 'updated'), tablePrefs: store.get('tables', {}),
23
24
  findings: new Map() };
24
25
  state.stageMode = state.mode === 'exec' ? 'exec' : 'plan'; // the stage's own mode, kept while Files is shown
25
26
  if (state.mode === 'files' && !window.ForgeFiles) state.mode = state.stageMode;
@@ -73,9 +74,11 @@ window.ForgeUI = (() => {
73
74
  const c = hash.match(/^#\/cloud(?:\/(runs|projects|models)\/([^/]+))?\/?$/);
74
75
  if (c) return { key: null, sel: null, cloud: { sel: c[1] === 'runs' ? decodeURIComponent(c[2]) : null, focus: c[1] === 'projects' ? decodeURIComponent(c[2]) : null, model: c[1] === 'models' ? decodeURIComponent(c[2]) : null } };
75
76
  if (/^#\/models\/?$/.test(hash)) return { key: null, sel: null, models: true };
77
+ if (/^#\/settings\/?$/.test(hash)) return { key: null, sel: null, settings: true };
76
78
  const m = hash.match(/^#\/runs\/([^/]+)(\/.*)?$/);
77
79
  if (!m) return { key: null, sel: null };
78
80
  const d = decodeURIComponent, rest = m[2] || '';
81
+ if (/^\/findings\/?$/.test(rest)) return { key: d(m[1]), sel: null, findings: true };
79
82
  let sel = null, r;
80
83
  if ((r = rest.match(/^\/tasks\/([^/]+)\/diff(?:\/(?!file\/|finding\/)([^/]+))?(?:\/finding\/([^/]+))?(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'task', task: d(r[1]), source: r[2] ? d(r[2]) : 'current', finding: r[3] ? d(r[3]) : null, file: r[4] ? d(r[4]) : null } };
81
84
  if ((r = rest.match(/^\/tasks\/([^/]+)\/fractal\/([^/]+)\/diff(?:\/file\/(.+?))?\/?$/))) return { key: d(m[1]), sel: { kind: 'task', id: d(r[1]) }, diff: { kind: 'fractal', task: d(r[1]), node: d(r[2]), file: r[3] ? d(r[3]) : null } };
@@ -171,8 +174,10 @@ window.ForgeUI = (() => {
171
174
  const active = parent.contains(document.activeElement) ? document.activeElement : null;
172
175
  const focusHref = active?.getAttribute('href');
173
176
  const own = [parent.scrollLeft, parent.scrollTop];
177
+ const held = window.ForgeControls ? window.ForgeControls.hold(parent) : null; // a control being edited rides through the rebuild
174
178
  parent.replaceChildren();
175
179
  build();
180
+ if (held) held.restore();
176
181
  if (!restore) return;
177
182
  for (const node of parent.querySelectorAll('details[data-keep]')) if (open.has(node.dataset.keep)) node.open = open.get(node.dataset.keep);
178
183
  for (const node of parent.querySelectorAll('[data-scroll]')) {
@@ -481,6 +486,41 @@ window.ForgeUI = (() => {
481
486
  }, 'Could not refresh the model results', error => { state.modelsError = error.message; if (state.models) renderModels(); });
482
487
  }
483
488
 
489
+ /* ---------- the settings (#/settings) ---------- */
490
+ function renderSettings() {
491
+ const main = $('main');
492
+ if (main.dataset.view === 'settings') { state.settingsView?.poll(); return; } // already drawn: its unsaved edits stay
493
+ setView(main, 'settings'); delete main.dataset.run;
494
+ main.replaceChildren();
495
+ const v = el('section', undefined, 'settings-view'); v.id = 'settings-view'; main.append(v);
496
+ state.settingsView = window.ForgeSettings.render(v, {
497
+ // a pick holds only while the current run is the one it was made from: the default is the current or most recent run
498
+ key: state.settingsRun && state.lastKey === state.settingsPickedAt ? state.settingsRun : state.lastKey,
499
+ runs: () => state.runs, onPick: key => { state.settingsRun = key; state.settingsPickedAt = state.lastKey; },
500
+ });
501
+ }
502
+
503
+ /* ---------- a run's open findings (#/runs/<key>/findings) ---------- */
504
+ function renderFindings() {
505
+ const main = $('main');
506
+ if (main.dataset.view === 'findings' && main.dataset.run === state.key) return; // already drawn: a toggle elsewhere must not reload it
507
+ setView(main, 'findings'); main.dataset.run = state.key;
508
+ main.replaceChildren();
509
+ const v = el('section', undefined, 'findings-view'); v.id = 'findings-view'; main.append(v);
510
+ window.ForgeFindings.render(v, {
511
+ key: state.key, run: state.cache.get(state.key)?.data, back: '#/runs/' + encodeURIComponent(state.key),
512
+ tablePrefs: id => state.tablePrefs[id], saveTablePrefs: (id, p) => { state.tablePrefs = { ...state.tablePrefs, [id]: p }; store.set('tables', state.tablePrefs); },
513
+ onCount: (n, scope) => { if (scope !== 'run') return; state.findingsCount.set(state.key, n); },
514
+ });
515
+ }
516
+ function findingsLink(key) {
517
+ const a = el('a', undefined, 'details-btn findings-link'); a.href = '#/runs/' + encodeURIComponent(key) + '/findings';
518
+ a.title = 'The findings this run left unfixed: dismiss them or file them as issues';
519
+ const n = state.findingsCount.get(key);
520
+ a.append(icon('panel-right'), el('span', 'Open findings'), el('span', n === undefined ? '' : String(n), 'findings-count'));
521
+ return a;
522
+ }
523
+
484
524
  /* ---------- a run ---------- */
485
525
  function viewToggle() {
486
526
  const g = el('div', undefined, 'seg'); g.setAttribute('role', 'group'); g.setAttribute('aria-label', 'Rendering');
@@ -581,14 +621,32 @@ window.ForgeUI = (() => {
581
621
  rec.append(icon('panel-right'), el('span', 'Run details'));
582
622
  rec.addEventListener('click', () => { Shell.toggle('insp', true); if (state.sel) select(null); });
583
623
  acts.append(rec);
624
+ if (!SNAP && window.ForgeFindings) acts.append(findingsLink(state.key));
584
625
  if (!SNAP) {
585
626
  const ex = el('button', undefined, 'export-btn'); ex.type = 'button'; ex.title = 'Download this run as one HTML file to share';
586
627
  ex.append(icon('download'), el('span', 'Export…'));
587
628
  ex.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) exportDialog(run); }); acts.append(ex);
588
629
  }
630
+ // the delete key stands apart from the routine ones, and only a server that takes writes shows it
631
+ if (!SNAP && window.ForgeRunDelete && window.ForgeUI && window.ForgeUI.controlsEnabled) {
632
+ window.ForgeUI.controlsEnabled().then(on => {
633
+ if (!on || acts.querySelector('.del-key')) return;
634
+ const del = el('button', undefined, 'del-key'); del.type = 'button'; del.title = 'Delete this run, after a dry run and a typed confirmation';
635
+ del.append(el('i', undefined, 'lamp st-fail'), el('span', 'Delete run…'));
636
+ del.addEventListener('click', () => { const run = state.cache.get(state.key)?.data; if (run) window.ForgeRunDelete.open(run, { onDeleted: runDeleted }); });
637
+ acts.append(el('span', undefined, 'del-gap'), del);
638
+ });
639
+ }
589
640
  bar.append(rp, acts);
590
641
  header.append(bar);
591
642
  }
643
+ // after a delete: the run is gone, so the page returns to the run list and drops what it held of the run
644
+ function runDeleted(run) {
645
+ state.cache.delete(run.key);
646
+ if (location.hash !== '#/') location.hash = '#/';
647
+ state.listText = null;
648
+ loadList();
649
+ }
592
650
  // the View popover's push buttons: pressed in while their part is drawn; the layers are the 3D stage's own
593
651
  function syncLayerKeys() {
594
652
  for (const b of document.querySelectorAll('.layer-key')) {
@@ -862,6 +920,8 @@ window.ForgeUI = (() => {
862
920
  if (!state.key) document.body.classList.remove('no-tasks');
863
921
  if (state.cloud) { renderCloud(); return; }
864
922
  if (state.models) { renderModels(); return; }
923
+ if (state.settings) { renderSettings(); return; }
924
+ if (state.key && state.findingsPage) { renderFindings(); return; }
865
925
  if (!state.key) { renderMap(); return; }
866
926
  const cached = state.cache.get(state.key);
867
927
  if (cached) renderRun(cached.data);
@@ -916,16 +976,17 @@ window.ForgeUI = (() => {
916
976
  if (window.ForgeNotify) window.ForgeNotify.observe(state.runs);
917
977
  renderList();
918
978
  if (state.cloud) renderCloud();
919
- else if (!state.key) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
979
+ else if (state.settings) state.settingsView?.poll(); // the picker follows the run list; the page stays
980
+ else if (!state.key && !state.models) renderMap({ rebuildBar: $('map-filters') ? !$('map-filters').contains(document.activeElement) : true });
920
981
  }, 'Could not refresh runs');
921
982
  }
922
983
  function loadRun(key) {
923
984
  return request('/api/runs/' + encodeURIComponent(key), result => {
924
985
  const changed = state.cache.get(key)?.text !== result.text;
925
986
  state.cache.set(key, result);
926
- if (state.key === key && changed) renderRun(result.data);
987
+ if (state.key === key && changed && !state.findingsPage) renderRun(result.data);
927
988
  }, 'Could not refresh run', error => {
928
- if (state.key !== key || state.cache.has(key)) return;
989
+ if (state.key !== key || state.cache.has(key) || state.findingsPage) return;
929
990
  if (error.status === 404) state.errors.delete('/api/runs/' + encodeURIComponent(key));
930
991
  setView($('main'), 'waiting');
931
992
  $('main').replaceChildren(el('p', error.status === 404 ?
@@ -940,7 +1001,7 @@ window.ForgeUI = (() => {
940
1001
  history.replaceState(null, '', '#/runs/' + encodeURIComponent(SNAP.meta.key));
941
1002
  parsed = parseHash(location.hash);
942
1003
  }
943
- const keyChanged = parsed.key !== state.key;
1004
+ const keyChanged = parsed.key !== state.key || (!!state.findingsPage && !parsed.findings); // the findings page replaced the run's DOM
944
1005
  state.key = parsed.key;
945
1006
  const selWas = JSON.stringify(state.sel || null);
946
1007
  state.sel = parsed.sel;
@@ -951,6 +1012,9 @@ window.ForgeUI = (() => {
951
1012
  state.convo = parsed.convo || null;
952
1013
  state.cloud = parsed.cloud || null;
953
1014
  state.models = !!parsed.models;
1015
+ state.settings = !!parsed.settings && !!window.ForgeSettings && !SNAP;
1016
+ if (parsed.key) state.lastKey = parsed.key;
1017
+ state.findingsPage = !!parsed.findings && !!window.ForgeFindings;
954
1018
  if (state.cloud && state.cloud.model) { // a model's runs: the cloud, filtered to it
955
1019
  cloudFilter({ models: { aliases: [state.cloud.model], role: 'any' } });
956
1020
  history.replaceState(null, '', '#/cloud'); state.cloud.model = null;
@@ -958,13 +1022,15 @@ window.ForgeUI = (() => {
958
1022
  document.body.classList.toggle('cloud-mode', !!state.cloud);
959
1023
  if (!state.key) document.body.classList.remove('no-tasks');
960
1024
  for (const a of document.querySelectorAll('.topbar .top-link')) {
961
- const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : !state.key ? '#/' : null);
1025
+ const here = a.getAttribute('href') === (state.cloud ? '#/cloud' : state.models ? '#/models' : state.settings ? '#/settings' : !state.key ? '#/' : null);
962
1026
  if (here) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
963
1027
  }
964
1028
  renderErrors();
965
1029
  markSelection();
966
1030
  if (state.cloud) { renderCloud(); return; }
967
1031
  if (state.models) { renderModels(); loadModels(); return; }
1032
+ if (state.settings) { renderSettings(); return; }
1033
+ if (state.key && state.findingsPage) { renderFindings(); return; }
968
1034
  if (!state.key) { renderMap(); return; }
969
1035
  const cached = state.cache.get(state.key);
970
1036
  if (cached) {
@@ -993,6 +1059,7 @@ window.ForgeUI = (() => {
993
1059
  loadList();
994
1060
  if (state.key) loadRun(state.key);
995
1061
  if (state.models) loadModels();
1062
+ if (state.settings) state.settingsView?.poll();
996
1063
  for (const node of document.querySelectorAll('[data-epoch]')) { const t = relative(node.dataset.epoch); node.textContent = node.dataset.short !== undefined ? t.replace(/^updated /, '') : t; }
997
1064
  }
998
1065
  // Poll faster while something runs, slower otherwise; never with refresh off, and a hidden tab only to notify.
@@ -1008,12 +1075,14 @@ window.ForgeUI = (() => {
1008
1075
  { id: 'cmd:cloud', label: 'Open the run cloud', sub: 'every run in 3D, grouped by project', ids: ['cloud'], href: '#/cloud' },
1009
1076
  { id: 'cmd:map', label: 'Open the map of all runs', sub: 'the front page', ids: ['map', 'home'], href: '#/' },
1010
1077
  { id: 'cmd:models', label: 'Open the model leaderboard', sub: 'pass rates, retries, time and tokens per model', ids: ['models', 'leaderboard'], href: '#/models' },
1078
+ ...(window.ForgeSettings ? [{ id: 'cmd:settings', label: 'Open the settings', sub: 'model registry, Jev, and the .forge files of a repository', ids: ['settings', 'config', 'verify', 'jev'], href: '#/settings' }] : []),
1011
1079
  { id: 'cmd:notify', label: 'Notifications', sub: 'what finished or failed, and browser notifications', ids: ['alerts', 'bell'], action: () => window.ForgeNotify && window.ForgeNotify.open() },
1012
1080
  { id: 'cmd:dim', label: state.dim === '3d' ? 'Switch to 2D' : 'Switch to 3D', sub: state.dim === '3d' ? 'graphs and charts, no WebGL' : 'loads three.js from cdn.jsdelivr.net', ids: ['2d', '3d'],
1013
1081
  action: () => { state.dim = state.dim === '3d' ? '2d' : '3d'; store.set('view', state.dim); if (state.dim === '3d') state.stageFailed = null; renderMain(); } },
1014
1082
  { id: 'cmd:refresh', label: state.paused ? 'Resume live refresh' : 'Pause live refresh', sub: 'polling of the runs', ids: ['refresh', 'pause'],
1015
1083
  action: () => { $('refresh').checked = state.paused; state.paused = !state.paused; refresh(); } },
1016
1084
  ];
1085
+ if (state.key && !SNAP && window.ForgeFindings) out.push({ id: 'cmd:findings', label: 'Open findings of this run…', sub: 'dismiss them or file them as GitHub issues', ids: ['findings', 'issues', 'dismiss'], href: '#/runs/' + encodeURIComponent(state.key) + '/findings' });
1017
1086
  if (run && !SNAP) out.push({ id: 'cmd:export', label: 'Export this run as one HTML file…', sub: 'to share; opens without forge-ui', ids: ['export', 'share'], action: () => exportDialog(run) });
1018
1087
  if (SNAP) return out.filter(c => c.id === 'cmd:dim').concat(run ? [{ id: 'cmd:mode', label: state.mode === 'plan' ? 'Show the execution' : 'Show the planned waves', sub: '', ids: ['exec', 'plan'], action: () => setMode(state.mode === 'plan' ? 'exec' : 'plan') }] : []);
1019
1088
  if (run && window.ForgeFiles) out.push({ id: 'cmd:files', label: 'Show which tasks touched which files', sub: 'the Files view of this run', ids: ['files', 'heatmap'], action: () => setMode('files') });
@@ -1077,3 +1146,6 @@ window.ForgeUI = (() => {
1077
1146
  init();
1078
1147
  return { refresh, state };
1079
1148
  })();
1149
+ // window.ForgeUI is uikit.js's shared helpers; the page's own handles (state, refresh) join it, where the browser
1150
+ // tests and ui/tools/readme_media.py read them.
1151
+ window.ForgeUI = Object.assign(window.ForgeUI || {}, window.ForgeApp);