@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.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +19 -1
- package/VERSION +1 -1
- package/docs/artifacts.md +17 -2
- package/docs/commands.md +50 -0
- package/docs/dataset.md +9 -0
- package/docs/decomposed-runs.md +42 -0
- package/docs/forge-ui.md +42 -6
- package/docs/reviews.md +14 -0
- package/forge +15 -0
- package/package.json +1 -1
- package/references/dataset.md +18 -1
- package/references/decompose.md +19 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- 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 +19 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +22 -0
- package/scripts/forge_dataset/importer.py +419 -17
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +37 -4
- package/ui/docs/api.md +184 -6
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +26 -1
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +80 -8
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/panel.js +24 -4
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +177 -2
- 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
|
-
|
|
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)
|
|
@@ -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:
|