@giamme/forge 0.9.0 → 0.11.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 +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -0,0 +1,587 @@
|
|
|
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
|
+
def _reviewer_outcomes(ctx):
|
|
542
|
+
# How the work each reviewer PASSed held up afterwards (`forge dataset reviewers`): advisory,
|
|
543
|
+
# read from the run dataset through the CLI, so forge_ui never opens the store itself.
|
|
544
|
+
ctx.route = 'reviewers'
|
|
545
|
+
try:
|
|
546
|
+
_query_params(ctx.query, ())
|
|
547
|
+
except BadBody as exc:
|
|
548
|
+
return _invalid(str(exc))
|
|
549
|
+
return ctx.call('dataset', 'reviewers', '--json')
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
ROUTES = [
|
|
553
|
+
('GET', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _show),
|
|
554
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _change),
|
|
555
|
+
('GET', re.compile(r'/api/runs/([0-9a-f]{12})/issues'), _issues_list),
|
|
556
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/issues'), _issues_file),
|
|
557
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/issues/dismiss'), _issues_dismiss),
|
|
558
|
+
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/delete'), _delete),
|
|
559
|
+
('GET', re.compile(r'/api/config()'), _config_get),
|
|
560
|
+
('POST', re.compile(r'/api/config()'), _config_set),
|
|
561
|
+
('GET', re.compile(r'/api/models/reviewers()'), _reviewer_outcomes),
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def match(method, path):
|
|
566
|
+
"""(handler, run key) for a route, or None."""
|
|
567
|
+
for verb, pattern, handler in ROUTES:
|
|
568
|
+
if verb == method:
|
|
569
|
+
found = pattern.fullmatch(path)
|
|
570
|
+
if found:
|
|
571
|
+
return handler, found.group(1)
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def has_route(method, path):
|
|
576
|
+
return match(method, path) is not None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def dispatch(server, cli, method, path, body, timeout=None, query=''):
|
|
580
|
+
"""Run the route for an authenticated request: (status, body, log route, run key), or None."""
|
|
581
|
+
found = match(method, path)
|
|
582
|
+
if found is None:
|
|
583
|
+
return None
|
|
584
|
+
handler, key = found
|
|
585
|
+
ctx = Context(server, cli, key, body, timeout, query)
|
|
586
|
+
status, obj = handler(ctx)
|
|
587
|
+
return status, obj, ctx.route, ctx.key
|
package/ui/forge_ui/export.py
CHANGED
|
@@ -126,6 +126,17 @@ class _Snapshot:
|
|
|
126
126
|
return text
|
|
127
127
|
|
|
128
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
|
+
|
|
129
140
|
def build(catalog, key, texts=False, paths=False, now=None):
|
|
130
141
|
"""(file name, HTML bytes) of run `key`, or None when there is no such run."""
|
|
131
142
|
base = f'/api/runs/{key}'
|
|
@@ -134,12 +145,15 @@ def build(catalog, key, texts=False, paths=False, now=None):
|
|
|
134
145
|
return None
|
|
135
146
|
snap = _Snapshot(catalog, key, texts, paths)
|
|
136
147
|
home = os.path.expanduser('~')
|
|
148
|
+
found = {}
|
|
137
149
|
for root in (run.get('path'), run.get('repo')):
|
|
138
150
|
if isinstance(root, str) and root.startswith('/') and len(root) > 1:
|
|
139
|
-
|
|
151
|
+
for spelling in _spellings(root.rstrip('/')):
|
|
152
|
+
found.setdefault(spelling, os.path.basename(root.rstrip('/')) or root)
|
|
140
153
|
if home and home != '/':
|
|
141
|
-
|
|
142
|
-
|
|
154
|
+
for spelling in _spellings(home):
|
|
155
|
+
found.setdefault(spelling, '~')
|
|
156
|
+
snap.roots = sorted(found.items(), key=lambda r: -len(r[0]))
|
|
143
157
|
summary = next((r for r in catalog.runs() if r.get('key') == key), None)
|
|
144
158
|
snap.api['/api/runs'] = {'runs': [snap.clean(summary)] if summary else []}
|
|
145
159
|
detail = snap.add(base, required=True)
|