@giamme/forge 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""The write path: routes that call the forge CLI, the one way forge-ui changes anything.
|
|
2
|
+
|
|
3
|
+
The server never writes a run dir, a repo or forge's config itself. A write is one call of
|
|
4
|
+
`<forge root>/forge ...` with an argv list, no shell and no stdin, in its own process group, under
|
|
5
|
+
a per-run lock; this module holds the route table, the body validation, the call and the mapping
|
|
6
|
+
of the CLI's exit code to an HTTP answer. server.py does the authentication and hands each
|
|
7
|
+
authenticated POST to `dispatch`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import urllib.parse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TIMEOUT_S = 60 # one CLI call; a test sets a shorter one
|
|
22
|
+
TERM_WAIT_S = 5 # between SIGTERM and SIGKILL of a timed-out call's process group
|
|
23
|
+
MAX_FIELD = 256
|
|
24
|
+
ISSUES_LIST_TIMEOUT_S = 120 # a listing asks gh for the filed state
|
|
25
|
+
ISSUES_FILE_TIMEOUT_S = 300 # filing up to 25 issues
|
|
26
|
+
DELETE_TIMEOUT_S = 900 # removing worktrees, branches and the archive can be slow
|
|
27
|
+
MAX_FPS = 25
|
|
28
|
+
MAX_LABELS = 5
|
|
29
|
+
MAX_LABEL = 50
|
|
30
|
+
MAX_REASON = 500
|
|
31
|
+
MAX_CONFIG_VALUE = 4096
|
|
32
|
+
FP_RE = re.compile(r'[0-9a-f]{12}')
|
|
33
|
+
CONFIG_KEYS = ('repo.verify', 'repo.setup', 'repo.verify-subset', 'jev.enabled')
|
|
34
|
+
DETAIL_MAX = 300
|
|
35
|
+
TIMEOUT_DETAIL = 'the operation may have partly completed; reload to see the current state'
|
|
36
|
+
|
|
37
|
+
# forge control's exit codes (scripts/forge-control.py) and the HTTP status each one gets.
|
|
38
|
+
_EXITS = {2: (400, 'invalid'), 3: (409, 'refused'), 4: (500, 'failed')}
|
|
39
|
+
|
|
40
|
+
# The forge configuration the CLIs read: which registry, which dataset store, the config and state homes.
|
|
41
|
+
# `forge ui open` starts the server without the FORGE_* variables of the shell it ran in, and hands it these,
|
|
42
|
+
# as that shell had them, in a file; every CLI call gets them on top of the server's own environment. None
|
|
43
|
+
# of them is a secret (forge-config.py's SECRET_WORDS), and no other FORGE_* variable is ever passed.
|
|
44
|
+
CLI_ENV_NAMES = ('FORGE_REGISTRY', 'FORGE_DATA_HOME', 'FORGE_DATASET', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME')
|
|
45
|
+
_CLI_ENV_PATHS = ('FORGE_REGISTRY', 'FORGE_DATA_HOME') # read relative to the shell's directory, not the server's
|
|
46
|
+
cli_env = {} # set once, before the server starts; empty when it was started by hand
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cli_env_from(environ):
|
|
50
|
+
"""The CLI_ENV_NAMES set in `environ` (an empty value counts as unset, as it does for the CLIs).
|
|
51
|
+
|
|
52
|
+
A relative FORGE_REGISTRY or FORGE_DATA_HOME is made absolute here: the server and its CLIs run in
|
|
53
|
+
another directory than the shell that named it.
|
|
54
|
+
"""
|
|
55
|
+
out = {}
|
|
56
|
+
for name in CLI_ENV_NAMES:
|
|
57
|
+
value = environ.get(name, '')
|
|
58
|
+
if not value:
|
|
59
|
+
continue
|
|
60
|
+
if name in _CLI_ENV_PATHS and not value.startswith('~'):
|
|
61
|
+
value = os.path.abspath(value)
|
|
62
|
+
out[name] = value
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_cli_env(value):
|
|
67
|
+
"""`value` if it is a mapping the launcher could have written, else ValueError."""
|
|
68
|
+
if not isinstance(value, dict):
|
|
69
|
+
raise ValueError('not a JSON object')
|
|
70
|
+
for name, text in value.items():
|
|
71
|
+
if name not in CLI_ENV_NAMES:
|
|
72
|
+
raise ValueError(f'{name!r} is not one of ' + ', '.join(CLI_ENV_NAMES))
|
|
73
|
+
if not isinstance(text, str) or not text or '\x00' in text:
|
|
74
|
+
raise ValueError(f'{name} is not a usable value')
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RunLocks:
|
|
79
|
+
"""One non-blocking lock per run key: a second write to a run that is being changed is refused."""
|
|
80
|
+
|
|
81
|
+
def __init__(self):
|
|
82
|
+
self._guard = threading.Lock()
|
|
83
|
+
self._held = set()
|
|
84
|
+
|
|
85
|
+
def acquire(self, key):
|
|
86
|
+
with self._guard:
|
|
87
|
+
if key in self._held:
|
|
88
|
+
return False
|
|
89
|
+
self._held.add(key)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
def release(self, key):
|
|
93
|
+
with self._guard:
|
|
94
|
+
self._held.discard(key)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class CliResult:
|
|
98
|
+
def __init__(self, code=None, stdout='', stderr='', timed_out=False, error=None):
|
|
99
|
+
self.code, self.stdout, self.stderr, self.timed_out, self.error = code, stdout, stderr, timed_out, error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _signal_group(pgid, sig):
|
|
103
|
+
try:
|
|
104
|
+
os.killpg(pgid, sig)
|
|
105
|
+
except (ProcessLookupError, PermissionError):
|
|
106
|
+
pass # already gone
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_cli(argv, timeout):
|
|
110
|
+
"""Run argv (a list: never a shell) in its own process group and return a CliResult.
|
|
111
|
+
|
|
112
|
+
On timeout the whole group gets SIGTERM, then SIGKILL after TERM_WAIT_S, and the leader is
|
|
113
|
+
reaped before this returns, so a caller that holds a lock can release it knowing nothing of
|
|
114
|
+
the call is still running. The CLI's environment is the server's plus `cli_env`.
|
|
115
|
+
"""
|
|
116
|
+
env = dict(os.environ, **cli_env) if cli_env else None
|
|
117
|
+
try:
|
|
118
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
119
|
+
start_new_session=True, close_fds=True, env=env)
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
return CliResult(error=f'cannot run the forge CLI: {exc.strerror or exc}')
|
|
122
|
+
try:
|
|
123
|
+
out, err = proc.communicate(timeout=timeout)
|
|
124
|
+
except subprocess.TimeoutExpired:
|
|
125
|
+
pgid = proc.pid # start_new_session made it the leader of a group of its own
|
|
126
|
+
_signal_group(pgid, signal.SIGTERM)
|
|
127
|
+
deadline = time.monotonic() + TERM_WAIT_S
|
|
128
|
+
while proc.poll() is None and time.monotonic() < deadline:
|
|
129
|
+
time.sleep(0.05)
|
|
130
|
+
# Children that outlive the leader (or ignore SIGTERM) die here; the leader is reaped by wait().
|
|
131
|
+
_signal_group(pgid, signal.SIGKILL)
|
|
132
|
+
proc.wait()
|
|
133
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
134
|
+
if pipe:
|
|
135
|
+
pipe.close()
|
|
136
|
+
return CliResult(timed_out=True)
|
|
137
|
+
except BaseException:
|
|
138
|
+
_signal_group(proc.pid, signal.SIGKILL)
|
|
139
|
+
proc.wait()
|
|
140
|
+
raise
|
|
141
|
+
return CliResult(code=proc.returncode, stdout=out.decode('utf-8', 'replace'), stderr=err.decode('utf-8', 'replace'))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _stdout_object(text):
|
|
145
|
+
try:
|
|
146
|
+
value = json.loads(text)
|
|
147
|
+
except (ValueError, RecursionError):
|
|
148
|
+
return None
|
|
149
|
+
return value if isinstance(value, dict) else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _last_line(text):
|
|
153
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
154
|
+
return lines[-1][:DETAIL_MAX] if lines else ''
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def map_exit(result):
|
|
158
|
+
"""(status, body) for a finished CliResult."""
|
|
159
|
+
if result.timed_out:
|
|
160
|
+
return 504, {'error': 'timeout', 'detail': TIMEOUT_DETAIL}
|
|
161
|
+
if result.error:
|
|
162
|
+
return 502, {'error': 'bad gateway', 'detail': result.error[:DETAIL_MAX]}
|
|
163
|
+
obj = _stdout_object(result.stdout)
|
|
164
|
+
if result.code == 0:
|
|
165
|
+
if obj is None:
|
|
166
|
+
return 502, {'error': 'bad gateway', 'detail': 'the forge CLI printed no JSON object'}
|
|
167
|
+
return 200, obj
|
|
168
|
+
status, error = _EXITS.get(result.code, (502, 'bad gateway'))
|
|
169
|
+
body = {'error': error, 'detail': _last_line(result.stderr) or f'the forge CLI exited {result.code}'}
|
|
170
|
+
if obj is not None:
|
|
171
|
+
body['result'] = obj
|
|
172
|
+
return status, body
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -------------------------------------------------------------------------------------- validation
|
|
176
|
+
|
|
177
|
+
class BadBody(Exception):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _text(body, name):
|
|
182
|
+
value = body.get(name)
|
|
183
|
+
if not isinstance(value, str) or not value:
|
|
184
|
+
raise BadBody(f'{name} must be a non-empty string')
|
|
185
|
+
# argv values: a leading '-' would be read as an option by the CLI, the rest cannot name anything.
|
|
186
|
+
if len(value) > MAX_FIELD or value.startswith('-') or not _no_control_chars(value):
|
|
187
|
+
raise BadBody(f'{name} is not an acceptable value')
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def validate_body(body):
|
|
192
|
+
"""The argv tail (after the plan dir) for a controls body; BadBody for a wrong shape or type.
|
|
193
|
+
|
|
194
|
+
Only shapes and types are checked: that a key exists, a value is in range or a spec runs is the
|
|
195
|
+
CLI's to say.
|
|
196
|
+
"""
|
|
197
|
+
action = body.get('action')
|
|
198
|
+
shapes = {'set': ({'action', 'key', 'value', 'task'}, ('key', 'value')),
|
|
199
|
+
'pin': ({'action', 'task', 'spec'}, ('task', 'spec')),
|
|
200
|
+
'unpin': ({'action', 'task'}, ('task',))}
|
|
201
|
+
if not isinstance(action, str) or action not in shapes:
|
|
202
|
+
raise BadBody('action must be one of set, pin, unpin')
|
|
203
|
+
allowed, required = shapes[action]
|
|
204
|
+
extra = sorted(set(body) - allowed)
|
|
205
|
+
if extra:
|
|
206
|
+
raise BadBody(f'unknown field {extra[0]!r} for {action}')
|
|
207
|
+
tail = [_text(body, name) for name in required]
|
|
208
|
+
if action == 'set' and 'task' in body:
|
|
209
|
+
tail += ['--task', _text(body, 'task')]
|
|
210
|
+
return action, tail
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -------------------------------------------------------------------------------------- the routes
|
|
214
|
+
|
|
215
|
+
class Context:
|
|
216
|
+
"""What a handler gets: the request's run key (and where it is), body and the server's tools."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, server, cli, key, body, timeout, query=''):
|
|
219
|
+
self.server, self.cli, self.key, self.body, self.timeout = server, cli, key, body, timeout
|
|
220
|
+
self.query = query
|
|
221
|
+
self.route = 'controls' # what the write log names; a handler may narrow it
|
|
222
|
+
|
|
223
|
+
def locate(self, kinds=('plan', 'solo')):
|
|
224
|
+
"""The run dir for the request's key when it is one of `kinds`, else None."""
|
|
225
|
+
found = self.server.catalog.locate(self.key)
|
|
226
|
+
return found[0] if found and found[1] in kinds else None
|
|
227
|
+
|
|
228
|
+
def call(self, *args, limit=None):
|
|
229
|
+
"""One CLI call, mapped to (status, body); the run's lock is the caller's to hold.
|
|
230
|
+
|
|
231
|
+
`limit` is the route's timeout; a timeout given to `dispatch` (a test's) replaces it."""
|
|
232
|
+
return map_exit(run_cli([str(self.cli), *args], self.timeout or limit or TIMEOUT_S))
|
|
233
|
+
|
|
234
|
+
def locked(self, work):
|
|
235
|
+
"""work() under the run's lock; 409 busy when another write to this run is in flight."""
|
|
236
|
+
locks = self.server.run_locks
|
|
237
|
+
if not locks.acquire(self.key):
|
|
238
|
+
return 409, {'error': 'busy'}
|
|
239
|
+
try:
|
|
240
|
+
return work()
|
|
241
|
+
finally:
|
|
242
|
+
locks.release(self.key)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _show(ctx):
|
|
246
|
+
path = ctx.locate(('plan',))
|
|
247
|
+
if path is None:
|
|
248
|
+
return 404, {'error': 'not found'}
|
|
249
|
+
return ctx.call('control', 'show', path, '--json')
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _change(ctx):
|
|
253
|
+
try:
|
|
254
|
+
action, tail = validate_body(ctx.body)
|
|
255
|
+
except BadBody as exc:
|
|
256
|
+
return 400, {'error': 'invalid', 'detail': str(exc)}
|
|
257
|
+
ctx.route = action
|
|
258
|
+
path = ctx.locate(('plan',))
|
|
259
|
+
if path is None:
|
|
260
|
+
return 404, {'error': 'not found'}
|
|
261
|
+
return ctx.locked(lambda: ctx.call('control', action, path, *tail, '--actor', 'ui', '--json'))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------- issues, delete and config routes
|
|
265
|
+
|
|
266
|
+
def _invalid(detail):
|
|
267
|
+
return 400, {'error': 'invalid', 'detail': detail}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _no_control_chars(value):
|
|
271
|
+
# A lone surrogate cannot be encoded into argv (the CLI refuses it too): treat it like a control character.
|
|
272
|
+
return not any(ord(ch) < 32 or ord(ch) == 127 or 0xD800 <= ord(ch) <= 0xDFFF for ch in value)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _check_fields(body, allowed):
|
|
276
|
+
extra = sorted(set(body) - set(allowed))
|
|
277
|
+
if extra:
|
|
278
|
+
raise BadBody(f'unknown field {extra[0]!r}')
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _scope(value):
|
|
282
|
+
if value not in ('run', 'repo'):
|
|
283
|
+
raise BadBody("scope must be 'run' or 'repo'")
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _flag(body, name, default=False):
|
|
288
|
+
value = body.get(name, default)
|
|
289
|
+
if not isinstance(value, bool):
|
|
290
|
+
raise BadBody(f'{name} must be true or false')
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _fingerprints(items, name='fingerprints'):
|
|
295
|
+
if not isinstance(items, list) or not 1 <= len(items) <= MAX_FPS:
|
|
296
|
+
raise BadBody(f'{name} must be a list of 1 to {MAX_FPS} fingerprints')
|
|
297
|
+
for item in items:
|
|
298
|
+
if not isinstance(item, str) or not FP_RE.fullmatch(item):
|
|
299
|
+
raise BadBody(f'{name} holds a value that is not a 12-hex fingerprint')
|
|
300
|
+
return list(dict.fromkeys(items)) # a repeated fingerprint is one finding
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _query_params(raw, allowed):
|
|
304
|
+
try:
|
|
305
|
+
pairs = urllib.parse.parse_qsl(raw, keep_blank_values=True, strict_parsing=bool(raw), max_num_fields=16)
|
|
306
|
+
except ValueError:
|
|
307
|
+
raise BadBody('the query string is malformed')
|
|
308
|
+
params = {}
|
|
309
|
+
for name, value in pairs:
|
|
310
|
+
if name not in allowed:
|
|
311
|
+
raise BadBody(f'unknown parameter {name!r}')
|
|
312
|
+
if name in params:
|
|
313
|
+
raise BadBody(f'parameter {name!r} is given twice')
|
|
314
|
+
params[name] = value
|
|
315
|
+
return params
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _bit(params, name):
|
|
319
|
+
value = params.get(name, '0')
|
|
320
|
+
if value not in ('0', '1'):
|
|
321
|
+
raise BadBody(f'{name} must be 0 or 1')
|
|
322
|
+
return value == '1'
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _issues_list(ctx):
|
|
326
|
+
ctx.route = 'issues'
|
|
327
|
+
try:
|
|
328
|
+
params = _query_params(ctx.query, ('scope', 'offline', 'plausible', 'dismissed', 'only'))
|
|
329
|
+
scope = _scope(params.get('scope', 'run'))
|
|
330
|
+
flags = [('offline', '--offline'), ('plausible', '--include-plausible'), ('dismissed', '--include-dismissed')]
|
|
331
|
+
extra = [flag for name, flag in flags if _bit(params, name)]
|
|
332
|
+
only = []
|
|
333
|
+
if 'only' in params:
|
|
334
|
+
only = _fingerprints(params['only'].split(','), 'only')
|
|
335
|
+
except BadBody as exc:
|
|
336
|
+
return _invalid(str(exc))
|
|
337
|
+
path = ctx.locate()
|
|
338
|
+
if path is None:
|
|
339
|
+
return 404, {'error': 'not found'}
|
|
340
|
+
argv = ['issues', path, '--json']
|
|
341
|
+
if scope == 'repo':
|
|
342
|
+
argv.append('--all-runs')
|
|
343
|
+
argv += extra
|
|
344
|
+
if only:
|
|
345
|
+
argv += ['--only', ','.join(only)]
|
|
346
|
+
return ctx.call(*argv, limit=ISSUES_LIST_TIMEOUT_S) # never cached: each request is a CLI call
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _issues_file(ctx):
|
|
350
|
+
body = ctx.body
|
|
351
|
+
ctx.route = 'issues'
|
|
352
|
+
try:
|
|
353
|
+
_check_fields(body, ('scope', 'fingerprints', 'include_plausible', 'labels'))
|
|
354
|
+
scope = _scope(body.get('scope', 'run'))
|
|
355
|
+
fps = _fingerprints(body.get('fingerprints'))
|
|
356
|
+
plausible = _flag(body, 'include_plausible')
|
|
357
|
+
labels = body.get('labels', [])
|
|
358
|
+
if not isinstance(labels, list) or len(labels) > MAX_LABELS:
|
|
359
|
+
raise BadBody(f'labels must be a list of at most {MAX_LABELS} strings')
|
|
360
|
+
for label in labels:
|
|
361
|
+
if (not isinstance(label, str) or not label.strip() or len(label) > MAX_LABEL
|
|
362
|
+
or label.startswith('-') or not _no_control_chars(label)):
|
|
363
|
+
raise BadBody(f'a label must be a text of 1 to {MAX_LABEL} characters, without control characters')
|
|
364
|
+
except BadBody as exc:
|
|
365
|
+
return _invalid(str(exc))
|
|
366
|
+
path = ctx.locate()
|
|
367
|
+
if path is None:
|
|
368
|
+
return 404, {'error': 'not found'}
|
|
369
|
+
argv = ['issues', path, '--only', ','.join(fps), '--max-issues', str(len(fps)), '--apply', '--json']
|
|
370
|
+
if scope == 'repo':
|
|
371
|
+
argv.append('--all-runs')
|
|
372
|
+
if plausible:
|
|
373
|
+
argv.append('--include-plausible')
|
|
374
|
+
for label in labels:
|
|
375
|
+
argv += ['--label', label]
|
|
376
|
+
return ctx.locked(lambda: ctx.call(*argv, limit=ISSUES_FILE_TIMEOUT_S))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _issues_dismiss(ctx):
|
|
380
|
+
body = ctx.body
|
|
381
|
+
ctx.route = 'undismiss' if body.get('undo') is True else 'dismiss'
|
|
382
|
+
try:
|
|
383
|
+
_check_fields(body, ('fingerprints', 'reason', 'undo'))
|
|
384
|
+
fps = _fingerprints(body.get('fingerprints'))
|
|
385
|
+
undo = _flag(body, 'undo')
|
|
386
|
+
reason = body.get('reason')
|
|
387
|
+
if reason is not None:
|
|
388
|
+
if not isinstance(reason, str) or len(reason) > MAX_REASON or not _no_control_chars(reason.replace('\n', ' ').replace('\t', ' ')):
|
|
389
|
+
raise BadBody(f'reason must be a text of at most {MAX_REASON} characters, without control characters (newline and tab are allowed)')
|
|
390
|
+
if undo:
|
|
391
|
+
raise BadBody('a reason goes with a dismissal, not with undo')
|
|
392
|
+
except BadBody as exc:
|
|
393
|
+
return _invalid(str(exc))
|
|
394
|
+
path = ctx.locate()
|
|
395
|
+
if path is None:
|
|
396
|
+
return 404, {'error': 'not found'}
|
|
397
|
+
argv = ['issues', path, '--json', '--undismiss' if undo else '--dismiss', ','.join(fps)]
|
|
398
|
+
if reason and reason.strip():
|
|
399
|
+
argv.append('--reason=' + reason) # '=' form: a reason like '-dup' is not read as an option
|
|
400
|
+
return ctx.locked(lambda: ctx.call(*argv))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
_dir_locks_guard = threading.Lock()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _server_lock(server, name):
|
|
407
|
+
"""The server-wide lock object `name`, made on first use (make_server does not know these)."""
|
|
408
|
+
with _dir_locks_guard:
|
|
409
|
+
lock = getattr(server, name, None)
|
|
410
|
+
if lock is None:
|
|
411
|
+
lock = RunLocks() if name == 'dir_locks' else threading.Lock()
|
|
412
|
+
setattr(server, name, lock)
|
|
413
|
+
return lock
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _delete(ctx):
|
|
417
|
+
body = ctx.body
|
|
418
|
+
ctx.route = 'delete'
|
|
419
|
+
try:
|
|
420
|
+
_check_fields(body, ('dry_run', 'archive', 'worktrees', 'branches'))
|
|
421
|
+
dry = _flag(body, 'dry_run')
|
|
422
|
+
archive = _flag(body, 'archive', True)
|
|
423
|
+
worktrees = _flag(body, 'worktrees')
|
|
424
|
+
branches = _flag(body, 'branches')
|
|
425
|
+
except BadBody as exc:
|
|
426
|
+
return _invalid(str(exc))
|
|
427
|
+
if ctx.locate() is None:
|
|
428
|
+
return 404, {'error': 'not found'}
|
|
429
|
+
|
|
430
|
+
def work():
|
|
431
|
+
# Looked up again under the run's lock: a delete that finished meanwhile left nothing to find.
|
|
432
|
+
path = ctx.locate()
|
|
433
|
+
if path is None:
|
|
434
|
+
return 404, {'error': 'not found'}
|
|
435
|
+
dir_locks = _server_lock(ctx.server, 'dir_locks')
|
|
436
|
+
real = os.path.realpath(path)
|
|
437
|
+
if not dir_locks.acquire(real):
|
|
438
|
+
return 409, {'error': 'busy'}
|
|
439
|
+
try:
|
|
440
|
+
argv = ['runs', 'rm', path, '--json', '--dry-run' if dry else '--yes']
|
|
441
|
+
if not archive:
|
|
442
|
+
argv.append('--no-archive')
|
|
443
|
+
if worktrees:
|
|
444
|
+
argv.append('--worktrees')
|
|
445
|
+
if branches:
|
|
446
|
+
argv.append('--branches')
|
|
447
|
+
return ctx.call(*argv, limit=DELETE_TIMEOUT_S)
|
|
448
|
+
finally:
|
|
449
|
+
dir_locks.release(real)
|
|
450
|
+
return ctx.locked(work)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _repo_of(path):
|
|
454
|
+
"""The repository the run's own `repo` file names, or None."""
|
|
455
|
+
try:
|
|
456
|
+
with open(os.path.join(path, 'repo'), encoding='utf-8') as handle:
|
|
457
|
+
line = handle.readline().strip()
|
|
458
|
+
except (OSError, UnicodeDecodeError):
|
|
459
|
+
return None
|
|
460
|
+
return line if line and os.path.isabs(line) and _no_control_chars(line) and os.path.isdir(line) else None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _config_run(ctx, key):
|
|
464
|
+
"""(repo, error answer) for the run key a config request names."""
|
|
465
|
+
if not isinstance(key, str) or not re.fullmatch(r'[0-9a-f]{12}', key):
|
|
466
|
+
return None, _invalid('run must be a 12-hex run key')
|
|
467
|
+
found = ctx.server.catalog.locate(key)
|
|
468
|
+
if not found:
|
|
469
|
+
return None, (404, {'error': 'not found'})
|
|
470
|
+
repo = _repo_of(found[0])
|
|
471
|
+
if repo is None:
|
|
472
|
+
return None, (409, {'error': 'refused', 'detail': 'the run names no repository directory'})
|
|
473
|
+
return repo, None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _name_run(ctx, key):
|
|
477
|
+
"""The log names the run a config request points at, as soon as the key is well-formed."""
|
|
478
|
+
if isinstance(key, str) and re.fullmatch(r'[0-9a-f]{12}', key):
|
|
479
|
+
ctx.key = key
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _config_get(ctx):
|
|
483
|
+
ctx.route = 'config'
|
|
484
|
+
_name_run(ctx, urllib.parse.parse_qs(ctx.query[:2000]).get('run', [None])[0])
|
|
485
|
+
try:
|
|
486
|
+
params = _query_params(ctx.query, ('run',))
|
|
487
|
+
except BadBody as exc:
|
|
488
|
+
return _invalid(str(exc))
|
|
489
|
+
repo, error = _config_run(ctx, params.get('run'))
|
|
490
|
+
if error:
|
|
491
|
+
return error
|
|
492
|
+
return ctx.call('config', 'show', '--repo', repo, '--json')
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _config_value(key, body):
|
|
496
|
+
if 'unset' in body:
|
|
497
|
+
if body['unset'] is not True or 'value' in body:
|
|
498
|
+
raise BadBody('give value, or unset: true, not both')
|
|
499
|
+
if key == 'jev.enabled':
|
|
500
|
+
raise BadBody('jev.enabled cannot be unset')
|
|
501
|
+
return 'unset', None
|
|
502
|
+
value = body.get('value')
|
|
503
|
+
if not isinstance(value, str) or not value.strip():
|
|
504
|
+
raise BadBody('value must be a non-empty string')
|
|
505
|
+
if len(value) > MAX_CONFIG_VALUE or value.startswith('-') or not _no_control_chars(value.replace('\n', ' ').replace('\t', ' ')):
|
|
506
|
+
raise BadBody('value is not an acceptable text')
|
|
507
|
+
if key == 'jev.enabled' and value not in ('true', 'false'):
|
|
508
|
+
raise BadBody('jev.enabled takes true or false')
|
|
509
|
+
if key == 'repo.verify-subset' and '{files}' not in value:
|
|
510
|
+
raise BadBody('repo.verify-subset must contain {files}')
|
|
511
|
+
return 'set', value
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _config_set(ctx):
|
|
515
|
+
body = ctx.body
|
|
516
|
+
ctx.route = 'config'
|
|
517
|
+
_name_run(ctx, body.get('run'))
|
|
518
|
+
try:
|
|
519
|
+
_check_fields(body, ('run', 'key', 'value', 'unset'))
|
|
520
|
+
key = body.get('key')
|
|
521
|
+
if key not in CONFIG_KEYS:
|
|
522
|
+
raise BadBody('key must be one of ' + ', '.join(CONFIG_KEYS))
|
|
523
|
+
verb, value = _config_value(key, body)
|
|
524
|
+
except BadBody as exc:
|
|
525
|
+
return _invalid(str(exc))
|
|
526
|
+
repo, error = _config_run(ctx, body.get('run'))
|
|
527
|
+
if error:
|
|
528
|
+
return error
|
|
529
|
+
argv = ['config', verb, key] + ([value] if verb == 'set' else []) + ['--repo', repo, '--json']
|
|
530
|
+
lock = _server_lock(ctx.server, 'config_lock')
|
|
531
|
+
if not lock.acquire(blocking=False):
|
|
532
|
+
return 409, {'error': 'busy'}
|
|
533
|
+
try:
|
|
534
|
+
return ctx.call(*argv)
|
|
535
|
+
finally:
|
|
536
|
+
lock.release()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# (method, path pattern, handler(ctx) -> (status, body)); group 1 of the pattern is the run key
|
|
540
|
+
# (the config routes name it in the query or body instead). New routes are added here.
|
|
541
|
+
ROUTES = [
|
|
542
|
+
('GET', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _show),
|
|
543
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _change),
|
|
544
|
+
('GET', re.compile(r'/api/runs/([0-9a-f]{12})/issues'), _issues_list),
|
|
545
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/issues'), _issues_file),
|
|
546
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/issues/dismiss'), _issues_dismiss),
|
|
547
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/delete'), _delete),
|
|
548
|
+
('GET', re.compile(r'/api/config()'), _config_get),
|
|
549
|
+
('POST', re.compile(r'/api/config()'), _config_set),
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def match(method, path):
|
|
554
|
+
"""(handler, run key) for a route, or None."""
|
|
555
|
+
for verb, pattern, handler in ROUTES:
|
|
556
|
+
if verb == method:
|
|
557
|
+
found = pattern.fullmatch(path)
|
|
558
|
+
if found:
|
|
559
|
+
return handler, found.group(1)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def has_route(method, path):
|
|
564
|
+
return match(method, path) is not None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def dispatch(server, cli, method, path, body, timeout=None, query=''):
|
|
568
|
+
"""Run the route for an authenticated request: (status, body, log route, run key), or None."""
|
|
569
|
+
found = match(method, path)
|
|
570
|
+
if found is None:
|
|
571
|
+
return None
|
|
572
|
+
handler, key = found
|
|
573
|
+
ctx = Context(server, cli, key, body, timeout, query)
|
|
574
|
+
status, obj = handler(ctx)
|
|
575
|
+
return status, obj, ctx.route, ctx.key
|
package/ui/forge_ui/export.py
CHANGED
|
@@ -26,10 +26,11 @@ from forge_ui import __version__, server
|
|
|
26
26
|
|
|
27
27
|
STATIC = server.STATIC_DIR
|
|
28
28
|
# what the exported page runs, in index.html's order; the cloud, the leaderboard and notifications need a server
|
|
29
|
-
SCRIPTS = ('runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'files.js', 'map3d.js',
|
|
29
|
+
SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
|
|
30
30
|
'search.js', 'app.js')
|
|
31
|
-
MODULES = ('iso.js', 'cloud3d.js', 'scene3d.js')
|
|
31
|
+
MODULES = ('iso.js', 'cloud3d.js', 'findings3d.js', 'stagectl.js', 'scene3d.js') # bundled in dependency order
|
|
32
32
|
DIFF_CAP = 1 << 20 # one diff's JSON
|
|
33
|
+
TRANSCRIPT_CAP = 2 << 20 # one execution's transcript
|
|
33
34
|
TOTAL_CAP = 25 << 20 # every response together
|
|
34
35
|
CDN = 'https://cdn.jsdelivr.net/npm/three@0.160.0/'
|
|
35
36
|
_EXEC = re.compile(r'(?:task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
|
|
@@ -125,6 +126,17 @@ class _Snapshot:
|
|
|
125
126
|
return text
|
|
126
127
|
|
|
127
128
|
|
|
129
|
+
def _spellings(path):
|
|
130
|
+
"""`path` and the other ways the same place is spelled: its real path and, when it is already real, the
|
|
131
|
+
symlinked form macOS conventionally reaches it by (/private/var is /var, /private/tmp is /tmp)."""
|
|
132
|
+
real = os.path.realpath(path)
|
|
133
|
+
found = [path]
|
|
134
|
+
for spelling in (real, real[len('/private'):] if real.startswith('/private/') else None):
|
|
135
|
+
if spelling and spelling != '/' and spelling not in found and os.path.realpath(spelling) == real:
|
|
136
|
+
found.append(spelling)
|
|
137
|
+
return found
|
|
138
|
+
|
|
139
|
+
|
|
128
140
|
def build(catalog, key, texts=False, paths=False, now=None):
|
|
129
141
|
"""(file name, HTML bytes) of run `key`, or None when there is no such run."""
|
|
130
142
|
base = f'/api/runs/{key}'
|
|
@@ -133,24 +145,29 @@ def build(catalog, key, texts=False, paths=False, now=None):
|
|
|
133
145
|
return None
|
|
134
146
|
snap = _Snapshot(catalog, key, texts, paths)
|
|
135
147
|
home = os.path.expanduser('~')
|
|
148
|
+
found = {}
|
|
136
149
|
for root in (run.get('path'), run.get('repo')):
|
|
137
150
|
if isinstance(root, str) and root.startswith('/') and len(root) > 1:
|
|
138
|
-
|
|
151
|
+
for spelling in _spellings(root.rstrip('/')):
|
|
152
|
+
found.setdefault(spelling, os.path.basename(root.rstrip('/')) or root)
|
|
139
153
|
if home and home != '/':
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
for spelling in _spellings(home):
|
|
155
|
+
found.setdefault(spelling, '~')
|
|
156
|
+
snap.roots = sorted(found.items(), key=lambda r: -len(r[0]))
|
|
142
157
|
summary = next((r for r in catalog.runs() if r.get('key') == key), None)
|
|
143
158
|
snap.api['/api/runs'] = {'runs': [snap.clean(summary)] if summary else []}
|
|
144
159
|
detail = snap.add(base, required=True)
|
|
145
160
|
tasks = [t for t in (detail.get('tasks') or []) if isinstance(t, dict) and t.get('id')]
|
|
146
161
|
for task in tasks:
|
|
147
162
|
snap.add(f"{base}/tasks/{_enc(task['id'])}")
|
|
148
|
-
|
|
163
|
+
names = sorted(set(_EXEC.findall(json.dumps(run))))
|
|
164
|
+
for name in names:
|
|
149
165
|
snap.add(f'{base}/executions/{name}')
|
|
150
166
|
reviews = [r.get('name') for r in (run.get('final_reviews') or []) if isinstance(r, dict) and r.get('name')]
|
|
151
167
|
for name in reviews:
|
|
152
168
|
snap.add(f'{base}/final-reviews/{_enc(name)}')
|
|
153
169
|
snap.add(f'{base}/files')
|
|
170
|
+
snap.add(f'{base}/findings')
|
|
154
171
|
fractal = run.get('fractal') or {}
|
|
155
172
|
if fractal.get('enabled') and fractal.get('available'):
|
|
156
173
|
for task in tasks:
|
|
@@ -170,6 +187,10 @@ def build(catalog, key, texts=False, paths=False, now=None):
|
|
|
170
187
|
snap.add(url, cap=DIFF_CAP)
|
|
171
188
|
for name in reviews:
|
|
172
189
|
snap.add(f'{base}/final-reviews/{_enc(name)}/diff', cap=DIFF_CAP)
|
|
190
|
+
# what each dwarf and reviewer said and ran: its conversation is all text
|
|
191
|
+
for name in names:
|
|
192
|
+
if name.startswith(('dwarf-', 'qa-')):
|
|
193
|
+
snap.add(f'{base}/executions/{name}/transcript', cap=TRANSCRIPT_CAP)
|
|
173
194
|
meta = {'format': 'forge-ui-export', 'version': 1, 'app': __version__, 'key': key,
|
|
174
195
|
'generated': now if now is not None else time.time(), 'texts': bool(texts), 'paths': bool(paths),
|
|
175
196
|
'omitted': snap.omitted}
|
|
@@ -182,14 +203,17 @@ def build(catalog, key, texts=False, paths=False, now=None):
|
|
|
182
203
|
def _page(snapshot, run):
|
|
183
204
|
index = (STATIC / 'index.html').read_text(encoding='utf-8')
|
|
184
205
|
importmap = re.search(r'<script type="importmap">(.*?)</script>', index, re.DOTALL).group(1)
|
|
185
|
-
body = re.search(r'<body
|
|
206
|
+
body_tag, body = re.search(r'(<body[^>]*>)(.*)</body>', index, re.DOTALL).groups()
|
|
207
|
+
body = re.sub(r'\s*<script src="[^"]*"></script>', '', body) # the page's own scripts are inlined below
|
|
186
208
|
css = (STATIC / 'style.css').read_text(encoding='utf-8')
|
|
209
|
+
# the engraving face travels inside the file
|
|
210
|
+
css = re.sub(r'url\(([A-Za-z0-9_.-]+\.woff2)\)', lambda m: 'url(data:font/woff2;base64,%s)' % base64.b64encode((STATIC / m.group(1)).read_bytes()).decode('ascii'), css)
|
|
187
211
|
scripts = [_script_safe((STATIC / name).read_text(encoding='utf-8')) for name in SCRIPTS if (STATIC / name).is_file()]
|
|
188
212
|
data = json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
|
|
189
213
|
stage = _script_safe(bundle())
|
|
190
214
|
icon = 'data:image/svg+xml;base64,' + base64.b64encode((STATIC / 'favicon.svg').read_bytes()).decode('ascii')
|
|
191
215
|
csp = ("default-src 'none'; script-src " + ' '.join([_sha(importmap)] + [_sha(s) for s in scripts]) + f" blob: {CDN}; "
|
|
192
|
-
f"style-src {_sha(css)}; img-src data: blob:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
|
|
216
|
+
f"style-src {_sha(css)}; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
|
|
193
217
|
title = 'forge-ui · ' + re.sub(r'[<>&"]', '', str(run.get('goal') or run.get('run_id') or 'run').split('\n')[0])[:120]
|
|
194
218
|
return ''.join([
|
|
195
219
|
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n',
|
|
@@ -197,7 +221,7 @@ def _page(snapshot, run):
|
|
|
197
221
|
'<meta name="referrer" content="no-referrer">\n',
|
|
198
222
|
f'<meta http-equiv="Content-Security-Policy" content="{csp}">\n',
|
|
199
223
|
f'<title>{title}</title>\n<link rel="icon" href="{icon}">\n',
|
|
200
|
-
f'<script type="importmap">{importmap}</script>\n<style>{css}</style>\n</head>\n
|
|
224
|
+
f'<script type="importmap">{importmap}</script>\n<style>{css}</style>\n</head>\n{body_tag}{body}',
|
|
201
225
|
f'<script type="application/json" id="forge-snapshot">{data}</script>\n',
|
|
202
226
|
f'<script type="text/plain" id="forge-stage">{stage}</script>\n',
|
|
203
227
|
''.join(f'<script>{s}</script>\n' for s in scripts),
|