@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. 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
@@ -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') # bundled in dependency order
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
- snap.roots.append((root.rstrip('/'), os.path.basename(root.rstrip('/')) or root))
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
- snap.roots.append((home, '~'))
141
- snap.roots.sort(key=lambda r: -len(r[0]))
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
- for name in sorted(set(_EXEC.findall(json.dumps(run)))):
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>(.*)</body>', index, re.DOTALL).group(1)
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<body>{body}',
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),