@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
package/scripts/forge-ui.py
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
prints a link to the run; every later `open` reuses it. State lives in
|
|
6
6
|
`${XDG_STATE_HOME:-~/.local/state}/forge/ui`: `server.json` (what we started), `server.log`
|
|
7
7
|
and `lock` (serialises launchers). The server exits by itself after FORGE_UI_IDLE_S idle seconds.
|
|
8
|
+
|
|
9
|
+
FORGE_UI_READONLY=1 starts (or insists on) a server that answers no write. The mode is recorded
|
|
10
|
+
with the server, and a running server of the other mode is replaced like one running old code.
|
|
11
|
+
|
|
12
|
+
The server runs without the shell's FORGE_* variables, but the CLIs its writes call need the forge
|
|
13
|
+
configuration (control.CLI_ENV_NAMES): `open` writes those to `cli-env.json` (0600) for the server to
|
|
14
|
+
load, records them in server.json, and refuses (exit 5) to reuse a server started with other values.
|
|
8
15
|
"""
|
|
9
16
|
from __future__ import annotations
|
|
10
17
|
|
|
@@ -38,6 +45,7 @@ LOCK_WAIT_S = 10
|
|
|
38
45
|
START_WAIT_S = 5
|
|
39
46
|
STOP_WAIT_S = 2
|
|
40
47
|
BROWSER_WAIT_S = 2 # a browser command that hangs must not hold `open` past its budget
|
|
48
|
+
CLI_ENV_FILE = 'cli-env.json'
|
|
41
49
|
|
|
42
50
|
|
|
43
51
|
def state_dir() -> Path:
|
|
@@ -123,13 +131,13 @@ def read_state(state):
|
|
|
123
131
|
return data if isinstance(data, dict) else None
|
|
124
132
|
|
|
125
133
|
|
|
126
|
-
def write_state(state, data):
|
|
127
|
-
handle, temp = tempfile.mkstemp(prefix='
|
|
134
|
+
def write_state(state, data, name='server.json'):
|
|
135
|
+
handle, temp = tempfile.mkstemp(prefix=name + '.', dir=str(state)) # mkstemp makes it 0600
|
|
128
136
|
try:
|
|
129
137
|
with os.fdopen(handle, 'w') as out:
|
|
130
138
|
json.dump(data, out, indent=1, sort_keys=True)
|
|
131
139
|
out.write('\n')
|
|
132
|
-
os.replace(temp, state /
|
|
140
|
+
os.replace(temp, state / name)
|
|
133
141
|
except BaseException:
|
|
134
142
|
try:
|
|
135
143
|
os.unlink(temp)
|
|
@@ -187,12 +195,25 @@ def terminate(pid) -> bool:
|
|
|
187
195
|
return wait_dead(pid, STOP_WAIT_S)
|
|
188
196
|
|
|
189
197
|
|
|
190
|
-
def
|
|
198
|
+
def read_only_wanted() -> bool:
|
|
199
|
+
return os.environ.get('FORGE_UI_READONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def is_read_only(answer) -> bool:
|
|
203
|
+
"""Whether a live /api/health answer is a read-only server (a server too old to say is one)."""
|
|
204
|
+
return answer.get('controls') is not True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _spawn(state, port, registry, idle, read_only=False, cli_env=None):
|
|
191
208
|
"""Start the server; returns (process, port) or (None, error text)."""
|
|
192
209
|
log = state / 'server.log'
|
|
193
210
|
env = {k: v for k, v in os.environ.items() if not k.startswith('FORGE_')}
|
|
194
|
-
|
|
195
|
-
|
|
211
|
+
# The forge configuration goes to the server's CLIs only, through a file: never into its own environment.
|
|
212
|
+
write_state(state, cli_env or {}, CLI_ENV_FILE)
|
|
213
|
+
command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--cli-env', str(state / CLI_ENV_FILE),
|
|
214
|
+
'--port', str(port), '--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
|
|
215
|
+
if read_only: # the server's environment has no FORGE_* variables, so the mode goes as a flag
|
|
216
|
+
command.append('--read-only')
|
|
196
217
|
with os.fdopen(open_regular(log, os.O_WRONLY | os.O_CREAT | os.O_TRUNC), 'w') as out:
|
|
197
218
|
proc = subprocess.Popen(command, cwd=str(state), stdin=subprocess.DEVNULL, stdout=out, stderr=out,
|
|
198
219
|
start_new_session=True, close_fds=True, env=env)
|
|
@@ -211,11 +232,11 @@ def _spawn(state, port, registry, idle):
|
|
|
211
232
|
return None, None
|
|
212
233
|
|
|
213
234
|
|
|
214
|
-
def start_server(state, port, registry, idle):
|
|
235
|
+
def start_server(state, port, registry, idle, read_only=False, cli_env=None):
|
|
215
236
|
"""Spawn a server, falling back once to a free port when the preferred one is taken."""
|
|
216
|
-
proc, result = _spawn(state, port, registry, idle)
|
|
237
|
+
proc, result = _spawn(state, port, registry, idle, read_only, cli_env)
|
|
217
238
|
if proc is None and result == 2 and port != 0:
|
|
218
|
-
proc, result = _spawn(state, 0, registry, idle)
|
|
239
|
+
proc, result = _spawn(state, 0, registry, idle, read_only, cli_env)
|
|
219
240
|
if proc is None:
|
|
220
241
|
return None
|
|
221
242
|
return {'pid': proc.pid, 'port': result}
|
|
@@ -258,6 +279,14 @@ def browse(url):
|
|
|
258
279
|
return 0
|
|
259
280
|
|
|
260
281
|
|
|
282
|
+
def config_change(recorded, current):
|
|
283
|
+
"""How the running server's forge configuration differs from this shell's, as text; '' when it does not."""
|
|
284
|
+
recorded = recorded if isinstance(recorded, dict) else {} # a record without one has none
|
|
285
|
+
names = sorted(name for name in set(recorded) | set(current) if recorded.get(name) != current.get(name))
|
|
286
|
+
return '; '.join(f'{name} is {current.get(name, "unset")} here, {recorded.get(name, "unset")} in the server'
|
|
287
|
+
for name in names)
|
|
288
|
+
|
|
289
|
+
|
|
261
290
|
def run_info(directory):
|
|
262
291
|
"""(repo, None) for a run dir, or (None, why not). Never raises and never blocks."""
|
|
263
292
|
marker = directory / 'repo'
|
|
@@ -269,7 +298,10 @@ def run_info(directory):
|
|
|
269
298
|
# a FIFO or a device named `repo` would block the read: only a regular file is read
|
|
270
299
|
if not stat.S_ISREG(os.stat(marker).st_mode):
|
|
271
300
|
return None, f'{marker} is not a regular file'
|
|
272
|
-
repo
|
|
301
|
+
# only the first line names the repo, as in forge_registry._repo_of (the runners register
|
|
302
|
+
# the same run dir under that line; anything after it must not change where it lands)
|
|
303
|
+
lines = marker.read_text().splitlines()
|
|
304
|
+
repo = lines[0].strip() if lines else ''
|
|
273
305
|
except (OSError, ValueError) as exc: # ValueError: not decodable text
|
|
274
306
|
return None, f'cannot read {marker}: {getattr(exc, "strerror", None) or exc}'
|
|
275
307
|
if not repo:
|
|
@@ -282,6 +314,7 @@ def run_info(directory):
|
|
|
282
314
|
def cmd_open(args):
|
|
283
315
|
import forge_registry
|
|
284
316
|
from forge_ui import __version__, build_id
|
|
317
|
+
from forge_ui.control import cli_env_from
|
|
285
318
|
from forge_ui.reader import run_key
|
|
286
319
|
|
|
287
320
|
try:
|
|
@@ -307,6 +340,8 @@ def cmd_open(args):
|
|
|
307
340
|
state = state_dir()
|
|
308
341
|
log = state / 'server.log'
|
|
309
342
|
registry = str(forge_registry.registry_dir())
|
|
343
|
+
read_only = read_only_wanted()
|
|
344
|
+
cli_env = cli_env_from(os.environ)
|
|
310
345
|
spawned = None
|
|
311
346
|
# Any OSError below is state we cannot use (state dir, lock, server.log, server.json) or a
|
|
312
347
|
# spawn that failed: exit 4. An unreadable or invalid server.json is not one: it means no server.
|
|
@@ -317,26 +352,35 @@ def cmd_open(args):
|
|
|
317
352
|
server = read_state(state)
|
|
318
353
|
answer = confirmed(server) if server else None
|
|
319
354
|
if answer:
|
|
320
|
-
# the record and the live answer must both name this code
|
|
321
|
-
if server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
322
|
-
|
|
355
|
+
# the record and the live answer must both name this code and the mode asked for
|
|
356
|
+
if (server.get('build') == build and answer.get('build') == build and server.get('registry') == registry
|
|
357
|
+
and bool(server.get('read_only')) == read_only and is_read_only(answer) == read_only):
|
|
358
|
+
# Reused, it would run this shell's writes with another registry and dataset, and a
|
|
359
|
+
# restart could cut off a write in flight: say so instead, and how to restart it.
|
|
360
|
+
change = config_change(server.get('cli_env'), cli_env)
|
|
361
|
+
if change:
|
|
362
|
+
_say(f'error: the running forge-ui (pid {server["pid"]}) has another forge configuration '
|
|
363
|
+
f'for its writes: {change}. Run `forge ui stop`, then open again.')
|
|
364
|
+
return 5
|
|
323
365
|
else:
|
|
324
366
|
terminate(server['pid'])
|
|
325
367
|
server = None
|
|
326
368
|
else:
|
|
327
369
|
server = None # dead, or a pid that is not our forge-ui: never signal it
|
|
328
370
|
if server is None:
|
|
329
|
-
spawned = start_server(state, port, registry, idle)
|
|
371
|
+
spawned = start_server(state, port, registry, idle, read_only, cli_env)
|
|
330
372
|
if spawned is None:
|
|
331
373
|
_say(f'error: the server did not start; see {log}')
|
|
332
374
|
return 4
|
|
333
|
-
server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log),
|
|
334
|
-
started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
|
|
375
|
+
server = dict(spawned, version=__version__, build=build, registry=registry, log=str(log), read_only=read_only,
|
|
376
|
+
cli_env=cli_env, started=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), opened=[])
|
|
335
377
|
opened = server.get('opened')
|
|
336
378
|
opened = opened if isinstance(opened, list) else []
|
|
337
379
|
mark = key or ''
|
|
338
380
|
# a key counts as opened only when a browser is really launched for it
|
|
339
|
-
|
|
381
|
+
wanted = browser_wanted(args)
|
|
382
|
+
first = mark not in opened and wanted
|
|
383
|
+
again = mark in opened and wanted # say so, or a repeated `open` looks like it did nothing
|
|
340
384
|
if first:
|
|
341
385
|
opened.append(mark)
|
|
342
386
|
server['opened'] = opened
|
|
@@ -354,6 +398,8 @@ def cmd_open(args):
|
|
|
354
398
|
print(f'forge-ui: {url}')
|
|
355
399
|
if first:
|
|
356
400
|
launch_browser(url)
|
|
401
|
+
elif again:
|
|
402
|
+
_say('a browser tab was already opened for this link, so no new one was opened; use the link above')
|
|
357
403
|
return 0
|
|
358
404
|
|
|
359
405
|
|
|
@@ -368,11 +414,13 @@ def cmd_status(args):
|
|
|
368
414
|
return 1
|
|
369
415
|
info = {'running': True, 'pid': server['pid'], 'port': server['port'],
|
|
370
416
|
'url': f'http://{HOST}:{server["port"]}/', 'version': answer.get('version'),
|
|
371
|
-
'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log')
|
|
417
|
+
'build': answer.get('build'), 'started': server.get('started'), 'log': server.get('log'),
|
|
418
|
+
'read_only': is_read_only(answer)}
|
|
372
419
|
if args.json:
|
|
373
420
|
print(json.dumps(info, sort_keys=True))
|
|
374
421
|
else:
|
|
375
|
-
|
|
422
|
+
mode = 'read-only' if info['read_only'] else 'read-write'
|
|
423
|
+
print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]}, {mode})')
|
|
376
424
|
return 0
|
|
377
425
|
|
|
378
426
|
|
|
@@ -403,8 +451,26 @@ def cmd_stop(args):
|
|
|
403
451
|
return 0
|
|
404
452
|
|
|
405
453
|
|
|
454
|
+
def load_cli_env(path):
|
|
455
|
+
"""The forge configuration `_spawn` wrote for the server's CLIs; OSError or ValueError otherwise."""
|
|
456
|
+
from forge_ui import control
|
|
457
|
+
with os.fdopen(open_regular(path, os.O_RDONLY), 'rb') as handle:
|
|
458
|
+
return control.check_cli_env(json.loads(handle.read()))
|
|
459
|
+
|
|
460
|
+
|
|
406
461
|
def serve(argv):
|
|
462
|
+
from forge_ui import control
|
|
407
463
|
from forge_ui.__main__ import main
|
|
464
|
+
if argv[:1] == ['--cli-env']:
|
|
465
|
+
if len(argv) < 2:
|
|
466
|
+
_say('error: --cli-env needs a file')
|
|
467
|
+
return 1 # not 2: start_server reads 2 as a taken port
|
|
468
|
+
try:
|
|
469
|
+
control.cli_env = load_cli_env(argv[1])
|
|
470
|
+
except (OSError, ValueError, RecursionError) as exc:
|
|
471
|
+
_say(f'error: cannot load the CLI configuration {argv[1]}: {getattr(exc, "strerror", None) or exc}')
|
|
472
|
+
return 1
|
|
473
|
+
argv = argv[2:]
|
|
408
474
|
return main(argv)
|
|
409
475
|
|
|
410
476
|
|
package/scripts/forge-version.py
CHANGED
|
@@ -10,6 +10,10 @@ VERSION, prints a single line saying so. It is silent offline, when the checkout
|
|
|
10
10
|
upstream, in CI (`CI` set) and when `FORGE_UPDATE_CHECK=off`, and it always exits 0: it never
|
|
11
11
|
changes a run's outcome. The last check is cached in
|
|
12
12
|
`${XDG_STATE_HOME:-~/.local/state}/forge/update-check.json`.
|
|
13
|
+
|
|
14
|
+
An npm installation (the package sits under a `node_modules` folder, the same rule as
|
|
15
|
+
forge-update.sh) has no git upstream: there the newest release is the package's `latest` on the
|
|
16
|
+
npm registry (`npm_config_registry` when set, as npm reads it), and the update command is npm's.
|
|
13
17
|
"""
|
|
14
18
|
from __future__ import annotations
|
|
15
19
|
|
|
@@ -21,6 +25,8 @@ import subprocess
|
|
|
21
25
|
import sys
|
|
22
26
|
import tempfile
|
|
23
27
|
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
24
30
|
from pathlib import Path
|
|
25
31
|
|
|
26
32
|
SKILL = Path(__file__).resolve().parent.parent
|
|
@@ -28,6 +34,8 @@ DAY_S = 24 * 3600
|
|
|
28
34
|
TIMEOUT_S = 5
|
|
29
35
|
_TAG = re.compile(r'^refs/tags/v(\d+(?:\.\d+)*)$')
|
|
30
36
|
_OFF = ('off', '0', 'false', 'no')
|
|
37
|
+
NPM_PACKAGE = '@giamme/forge'
|
|
38
|
+
NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
def state_file() -> Path:
|
|
@@ -84,6 +92,34 @@ def checkout(source: Path) -> dict:
|
|
|
84
92
|
return info
|
|
85
93
|
|
|
86
94
|
|
|
95
|
+
def npm_install(source: Path) -> dict:
|
|
96
|
+
"""An npm installation's facts, like checkout()'s: empty when `source` is not under node_modules."""
|
|
97
|
+
if 'node_modules' not in source.resolve().parts:
|
|
98
|
+
return {}
|
|
99
|
+
try:
|
|
100
|
+
name = json.loads((source / 'package.json').read_text(encoding='utf-8')).get('name') or NPM_PACKAGE
|
|
101
|
+
except (OSError, ValueError, UnicodeDecodeError, AttributeError):
|
|
102
|
+
name = NPM_PACKAGE
|
|
103
|
+
registry = os.environ.get('npm_config_registry') or os.environ.get('NPM_CONFIG_REGISTRY') or NPM_REGISTRY
|
|
104
|
+
url = registry.rstrip('/') + '/' + urllib.parse.quote(name, safe='@') + '/latest'
|
|
105
|
+
return dict(npm=name, remote='npm', url=url)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def update_command(info: dict) -> str:
|
|
109
|
+
return f'npm install -g {info["npm"]}@latest && forge install' if info.get('npm') else 'forge update'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def npm_latest(url: str) -> str | None:
|
|
113
|
+
"""The `latest` version on the registry, or None when it cannot be read in time."""
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={'Accept': 'application/json'}),
|
|
116
|
+
timeout=TIMEOUT_S) as reply:
|
|
117
|
+
version = json.loads(reply.read().decode('utf-8')).get('version')
|
|
118
|
+
except Exception: # offline, HTTP error, a proxy page instead of JSON: no answer, never a failure
|
|
119
|
+
return None
|
|
120
|
+
return version if isinstance(version, str) and re.fullmatch(r'\d+(?:\.\d+)*', version) else None
|
|
121
|
+
|
|
122
|
+
|
|
87
123
|
def latest_release(source: Path, remote: str) -> str | None:
|
|
88
124
|
"""The highest vX[.Y[.Z]] tag on the remote, or None when it cannot be reached in time."""
|
|
89
125
|
out = _git(source, 'ls-remote', '--tags', '--refs', '--', remote, 'v*')
|
|
@@ -116,7 +152,7 @@ def write_cache(data: dict) -> None:
|
|
|
116
152
|
def check(source: Path, info: dict) -> dict:
|
|
117
153
|
"""Ask the upstream now and cache the answer. Offline keeps the last known release."""
|
|
118
154
|
cache = read_cache()
|
|
119
|
-
found = latest_release(source, info['remote'])
|
|
155
|
+
found = npm_latest(info['url']) if info.get('npm') else latest_release(source, info['remote'])
|
|
120
156
|
same = cache.get('url') == info['url']
|
|
121
157
|
entry = dict(checked=time.time(), url=info['url'],
|
|
122
158
|
latest=found if found else (cache.get('latest') if same else None), reached=found is not None)
|
|
@@ -131,15 +167,15 @@ def disabled() -> bool:
|
|
|
131
167
|
return bool(ci) and ci not in _OFF
|
|
132
168
|
|
|
133
169
|
|
|
134
|
-
def available_line(latest: str, current: str) -> str:
|
|
135
|
-
return f'forge {latest} is available (you have {current}):
|
|
170
|
+
def available_line(latest: str, current: str, info: dict | None = None) -> str:
|
|
171
|
+
return f'forge {latest} is available (you have {current}): {update_command(info or {})}'
|
|
136
172
|
|
|
137
173
|
|
|
138
174
|
def notice(source: Path) -> None:
|
|
139
175
|
if disabled():
|
|
140
176
|
return
|
|
141
177
|
current = local_version(source)
|
|
142
|
-
info = checkout(source)
|
|
178
|
+
info = checkout(source) or npm_install(source)
|
|
143
179
|
if not current or not info.get('remote'):
|
|
144
180
|
return
|
|
145
181
|
cache = read_cache()
|
|
@@ -148,22 +184,26 @@ def notice(source: Path) -> None:
|
|
|
148
184
|
return # checked (and told) within the last day
|
|
149
185
|
entry = check(source, info)
|
|
150
186
|
if newer(entry['latest'], current):
|
|
151
|
-
print(available_line(entry['latest'], current))
|
|
187
|
+
print(available_line(entry['latest'], current, info))
|
|
152
188
|
|
|
153
189
|
|
|
154
190
|
def show(source: Path, ask: bool) -> int:
|
|
155
191
|
current = local_version(source)
|
|
156
192
|
print(f'forge {current or "unknown"}')
|
|
157
|
-
info = checkout(source)
|
|
193
|
+
info = checkout(source) or npm_install(source)
|
|
158
194
|
if not info:
|
|
159
195
|
print(f'checkout: {source} (not a git checkout; update it with forge update --source <checkout>)')
|
|
160
196
|
return 0
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
|
|
197
|
+
if info.get('npm'):
|
|
198
|
+
print(f'installed: {source} (npm package {info["npm"]})')
|
|
199
|
+
print(f'upstream: npm registry ({info["url"]})')
|
|
165
200
|
else:
|
|
166
|
-
|
|
201
|
+
where = info['branch'] or 'detached HEAD'
|
|
202
|
+
print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
|
|
203
|
+
if info.get('remote'):
|
|
204
|
+
print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
|
|
205
|
+
else:
|
|
206
|
+
print('upstream: none')
|
|
167
207
|
entry = check(source, info) if ask and info.get('remote') else read_cache()
|
|
168
208
|
if ask and info.get('remote') and not entry.get('reached'):
|
|
169
209
|
print(f'latest: could not reach {info["remote"]}')
|
|
@@ -172,9 +212,11 @@ def show(source: Path, ask: bool) -> int:
|
|
|
172
212
|
if isinstance(entry.get('checked'), (int, float)) else 'unknown'
|
|
173
213
|
print(f'latest: {entry["latest"]} (checked {when})')
|
|
174
214
|
if newer(entry['latest'], current):
|
|
175
|
-
print(available_line(entry['latest'], current))
|
|
215
|
+
print(available_line(entry['latest'], current, info))
|
|
176
216
|
else:
|
|
177
217
|
print('latest: not checked yet; run forge version --check')
|
|
218
|
+
if info.get('npm'):
|
|
219
|
+
print(f'update: {update_command(info)}')
|
|
178
220
|
return 0
|
|
179
221
|
|
|
180
222
|
|
|
@@ -18,6 +18,8 @@ can still be answered from old runs.
|
|
|
18
18
|
Capture is fail-open in the same way Jev is. Every entry point swallows its own errors
|
|
19
19
|
into errors.log, never prints, never raises, and bounds its wait on the database with the
|
|
20
20
|
SQLite busy timeout. It uses no signals, because Fractal captures from worker threads.
|
|
21
|
+
record_strict() is the one exception, for a caller that must know an event is stored (it
|
|
22
|
+
raises DatasetError instead of returning 0).
|
|
21
23
|
FORGE_DATASET=off turns it off.
|
|
22
24
|
|
|
23
25
|
Nothing that builds a prompt may ever read this store: a reviewer that could see earlier
|
|
@@ -25,12 +27,14 @@ runs' verdicts would no longer be independent.
|
|
|
25
27
|
"""
|
|
26
28
|
from __future__ import annotations
|
|
27
29
|
|
|
30
|
+
import contextlib
|
|
28
31
|
import gzip
|
|
29
32
|
import hashlib
|
|
30
33
|
import json
|
|
31
34
|
import os
|
|
32
35
|
import sqlite3
|
|
33
36
|
import subprocess
|
|
37
|
+
import threading
|
|
34
38
|
import time
|
|
35
39
|
import traceback
|
|
36
40
|
import uuid
|
|
@@ -253,14 +257,46 @@ def put_blob(data: bytes | str, kind: str = 'text') -> dict:
|
|
|
253
257
|
n=len(data), kept=len(stored), cut=span)
|
|
254
258
|
|
|
255
259
|
|
|
260
|
+
_BLOB_WATCH = threading.local()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@contextlib.contextmanager
|
|
264
|
+
def watch_blob_failures():
|
|
265
|
+
"""Collect this thread's failed blob writes while the block runs (yields the list).
|
|
266
|
+
|
|
267
|
+
Only a caller that asks pays for the record: a long-lived process that never watches
|
|
268
|
+
keeps nothing, and another thread's failed write never lands in this thread's list.
|
|
269
|
+
"""
|
|
270
|
+
previous = getattr(_BLOB_WATCH, 'failures', None)
|
|
271
|
+
failures: list[str] = []
|
|
272
|
+
_BLOB_WATCH.failures = failures
|
|
273
|
+
try:
|
|
274
|
+
yield failures
|
|
275
|
+
finally:
|
|
276
|
+
_BLOB_WATCH.failures = previous
|
|
277
|
+
|
|
278
|
+
|
|
256
279
|
def put_file(path: str | Path, kind: str = 'text') -> dict | None:
|
|
280
|
+
"""Store a file's content; None when it is absent, unreadable, or could not be stored.
|
|
281
|
+
|
|
282
|
+
A failed write into the blob store is also noted for a watch_blob_failures() block: the caller
|
|
283
|
+
still gets None (live capture fails open), but a strict importer can tell "no file" from
|
|
284
|
+
"content lost" and refuse to let the event win its rid without it.
|
|
285
|
+
"""
|
|
257
286
|
path = Path(path)
|
|
258
287
|
try:
|
|
259
288
|
if not path.is_file():
|
|
260
289
|
return None
|
|
261
|
-
|
|
290
|
+
data = path.read_bytes()
|
|
262
291
|
except OSError:
|
|
263
292
|
return None
|
|
293
|
+
try:
|
|
294
|
+
return put_blob(data, kind)
|
|
295
|
+
except OSError as error:
|
|
296
|
+
failures = getattr(_BLOB_WATCH, 'failures', None)
|
|
297
|
+
if failures is not None:
|
|
298
|
+
failures.append('%s: cannot store content: %s' % (path, error))
|
|
299
|
+
return None
|
|
264
300
|
|
|
265
301
|
|
|
266
302
|
def read_blob(stored_sha: str) -> bytes:
|
|
@@ -384,13 +420,29 @@ def insert(db: sqlite3.Connection, event: dict) -> bool:
|
|
|
384
420
|
return cursor.rowcount == 1
|
|
385
421
|
|
|
386
422
|
|
|
387
|
-
|
|
388
|
-
"""
|
|
389
|
-
|
|
423
|
+
class DatasetError(Exception):
|
|
424
|
+
"""An event could not be persisted (or an import/archive could not be completed)."""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _record(events: list[dict], repo: str | Path | None, where: str, strict: bool) -> int:
|
|
428
|
+
"""The one writer behind record() and record_strict(), so the two cannot drift.
|
|
429
|
+
|
|
430
|
+
Lenient: any problem is logged (or, for a refused location, ignored) and counts as 0.
|
|
431
|
+
Strict: the same problems raise DatasetError, because 0 new rows must keep meaning
|
|
432
|
+
"everything was already there".
|
|
433
|
+
"""
|
|
434
|
+
if not events:
|
|
390
435
|
return 0
|
|
391
436
|
try:
|
|
392
|
-
|
|
393
|
-
|
|
437
|
+
if not enabled():
|
|
438
|
+
if strict:
|
|
439
|
+
raise DatasetError('the dataset is off (FORGE_DATASET=off)')
|
|
440
|
+
return 0
|
|
441
|
+
refused = location_refused(repo)
|
|
442
|
+
if refused:
|
|
443
|
+
# Not logged: errors.log lives in the same refused place.
|
|
444
|
+
if strict:
|
|
445
|
+
raise DatasetError(refused)
|
|
394
446
|
return 0
|
|
395
447
|
# Fingerprinting the code reads every shipped file and runs git once per process:
|
|
396
448
|
# done under the write lock, it held every other writer past its busy timeout.
|
|
@@ -414,11 +466,30 @@ def record(events: list[dict], repo: str | Path | None = None, where: str = 'rec
|
|
|
414
466
|
raise
|
|
415
467
|
finally:
|
|
416
468
|
db.close()
|
|
469
|
+
except DatasetError:
|
|
470
|
+
raise
|
|
417
471
|
except Exception as error:
|
|
418
472
|
log_error(where, error)
|
|
473
|
+
if strict:
|
|
474
|
+
raise DatasetError('%s: %s' % (where, error)) from error
|
|
419
475
|
return 0
|
|
420
476
|
|
|
421
477
|
|
|
478
|
+
def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
|
|
479
|
+
"""Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
|
|
480
|
+
return _record(events, repo, where, strict=False)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def record_strict(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
|
|
484
|
+
"""Like record(), but a failure to persist raises DatasetError instead of returning 0.
|
|
485
|
+
|
|
486
|
+
For callers that must know every event is in the store (`forge runs rm` before it
|
|
487
|
+
deletes a run dir). Covers a refused location, a store that is off or unwritable, a
|
|
488
|
+
sqlite error and a database still locked after the busy timeout.
|
|
489
|
+
"""
|
|
490
|
+
return _record(events, repo, where, strict=True)
|
|
491
|
+
|
|
492
|
+
|
|
422
493
|
def create_once(path: Path, text: str) -> str:
|
|
423
494
|
"""Write `text` to `path` unless it exists; return whichever content won.
|
|
424
495
|
|
|
@@ -155,8 +155,11 @@ def point(name: str, directory: str | None, task: str | None = None, values: dic
|
|
|
155
155
|
refs[label] = put_blob(value, 'log')
|
|
156
156
|
task = task or ctx.get('task')
|
|
157
157
|
data['blobs'] = refs
|
|
158
|
-
|
|
159
|
-
|
|
158
|
+
if name in h.INTERVENTIONS:
|
|
159
|
+
rid = h.intervention_rid(name, uid, task, values or {})
|
|
160
|
+
else:
|
|
161
|
+
rid = 'event:%s:%s' % (name, h.sha256(json.dumps([uid, task, time.time_ns(), data], sort_keys=True,
|
|
162
|
+
default=str).encode())[:24])
|
|
160
163
|
record([dict(rid=rid, kind=kind, ts=time.time(), run=uid, run_id=ctx.get('run_id'),
|
|
161
164
|
mode=ctx.get('mode'), task=task, parent=env['parent'], inv=env['inv'], data=data)],
|
|
162
165
|
repo=repo or ctx.get('repo'), where='event:' + name)
|
|
@@ -28,7 +28,7 @@ from . import SCRIPTS, put_blob, put_file, sha256
|
|
|
28
28
|
DISPATCH_ROLES = ('dwarf', 'qa', 'planner')
|
|
29
29
|
PIPELINE_ROLES = ('task', 'solo', 'verification', 'parallel')
|
|
30
30
|
TASKS_COLS = ('id', 'deps', 'difficulty', 'files', 'dwarf', 'qa', 'title')
|
|
31
|
-
STRUCTURED = ('codex', 'claude', 'grok')
|
|
31
|
+
STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode') # as forge-runtime.py's: .last is the message, not the log
|
|
32
32
|
# Plan files that change what a plan decides, added to its rid only when present (see plan_event).
|
|
33
33
|
PLAN_RID_LATER = ('decompose_level', 'shadow_qa', 'shadow_rate', 'explore')
|
|
34
34
|
|
|
@@ -345,6 +345,28 @@ def run_uid(run_dir: Path, create: bool) -> str:
|
|
|
345
345
|
return legacy_uid(Path(run_dir))
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
# --- interventions: a human's (or the policy's) change of course ------------------------
|
|
349
|
+
# Escalations and control changes are point events, recorded at the moment they happen, but
|
|
350
|
+
# the run dir also keeps them (tasks/<id>/escalations.tsv, control.log). Their rid hangs on
|
|
351
|
+
# what they say rather than on when they were captured, so live capture and an import of the
|
|
352
|
+
# run dir agree on it and a missed live event is filled in later without a duplicate.
|
|
353
|
+
|
|
354
|
+
INTERVENTIONS = {
|
|
355
|
+
'escalation': ('after_attempt', 'from', 'to', 'source'),
|
|
356
|
+
'control': ('at', 'actor', 'action', 'key', 'value', 'previous'),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def intervention_data(name: str, values: dict) -> dict:
|
|
361
|
+
"""The fields that identify an intervention, as the strings `--set k=v` delivers."""
|
|
362
|
+
return {field: '' if values.get(field) is None else str(values[field]) for field in INTERVENTIONS[name]}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def intervention_rid(name: str, uid: str | None, task: str | None, values: dict) -> str:
|
|
366
|
+
canonical = json.dumps([uid, task, intervention_data(name, values)], sort_keys=True)
|
|
367
|
+
return 'event:%s:%s' % (name, sha256(canonical.encode())[:24])
|
|
368
|
+
|
|
369
|
+
|
|
348
370
|
# --- dispatch: one model call ----------------------------------------------------------
|
|
349
371
|
|
|
350
372
|
def dispatch_event(attempt: Path, root: Path, role: str, harness: str, rc, prov: dict, src: str = 'live') -> dict:
|