@giamme/forge 0.9.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 (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. package/ui/forge_ui/static/uikit.js +161 -0
@@ -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)
@@ -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: