@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
@@ -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='server.json.', dir=str(state))
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 / 'server.json')
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 _spawn(state, port, registry, idle):
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
- command = [sys.executable, '-I', str(Path(__file__).resolve()), '_serve', '--port', str(port),
195
- '--quiet', '--registry-dir', registry, '--idle-exit', str(idle)]
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 = marker.read_text().strip()
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
- pass # reuse
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
- first = mark not in opened and browser_wanted(args)
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
- print(f'forge-ui: running at {info["url"]} (pid {info["pid"]}, started {info["started"]})')
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
 
@@ -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}): forge update'
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
- where = info['branch'] or 'detached HEAD'
162
- print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
163
- if info.get('remote'):
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
- print('upstream: none')
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
- return put_blob(path.read_bytes(), kind)
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
- def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
388
- """Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
389
- if not enabled() or not events:
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
- # Refused silently: errors.log lives in the same refused place.
393
- if location_refused(repo):
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
- rid = 'event:%s:%s' % (name, h.sha256(json.dumps([uid, task, time.time_ns(), data], sort_keys=True,
159
- default=str).encode())[:24])
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: