@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.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +19 -1
- package/VERSION +1 -1
- package/docs/artifacts.md +17 -2
- package/docs/commands.md +50 -0
- package/docs/dataset.md +9 -0
- package/docs/decomposed-runs.md +42 -0
- package/docs/forge-ui.md +42 -6
- package/docs/reviews.md +14 -0
- package/forge +15 -0
- package/package.json +1 -1
- package/references/dataset.md +18 -1
- package/references/decompose.md +19 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +22 -0
- package/scripts/forge_dataset/importer.py +419 -17
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +37 -4
- package/ui/docs/api.md +184 -6
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +26 -1
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +80 -8
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/panel.js +24 -4
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +177 -2
- package/ui/forge_ui/static/uikit.js +161 -0
package/ui/forge_ui/server.py
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
|
-
"""Local HTTP server: the JSON API of docs/api.md and the static page.
|
|
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
|
-
|
|
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:
|
|
1
|
+
/* forge-ui: the page. It reads the API; a change to a run goes through the write helpers in uikit.js (see controls.js). All API values enter the DOM as text.
|
|
2
2
|
Views: the map of every run (#/), the run cloud (#/cloud, full screen: cloudui.js over cloud3d.js), and a
|
|
3
3
|
run (#/runs/<key>) with its stage (3D, or the 2D graph and chart), the inspector beside it and the task table under it. The stage is one element that moves between views,
|
|
4
4
|
so the 3D scene (and its WebGL context) survives navigation and refreshes. */
|
|
5
|
-
window.
|
|
5
|
+
window.ForgeApp = (() => {
|
|
6
6
|
'use strict';
|
|
7
7
|
const RM = window.ForgeRunModel;
|
|
8
8
|
// an exported page (forge_ui/export.py): one run and the API responses it needs, no server behind it
|
|
@@ -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 (
|
|
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);
|