@giamme/forge 0.9.0 → 0.11.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 +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""`forge dataset reviewers`: how the work each reviewer passed held up afterwards.
|
|
2
|
+
|
|
3
|
+
A report card per reviewer (qa) model. For every attempt a primary reviewer PASSed whose
|
|
4
|
+
reviewed commit was integrated and then followed by `forge dataset outcomes`, the outcome
|
|
5
|
+
at one horizon (default 14 days) says whether the work held: it did not if a later commit
|
|
6
|
+
reverted it, or if fewer than 80% of the lines it added were still there (rewritten; the
|
|
7
|
+
same rule as the export's `survives` label). The rate of work that did not hold has a
|
|
8
|
+
95% Wilson interval, and is only given from MIN_LANDED outcomes on: below that the row
|
|
9
|
+
says "not enough data" rather than a rate a handful of tasks would make up.
|
|
10
|
+
|
|
11
|
+
Advisory only. Nothing that builds a prompt or routes a task reads this report, and it
|
|
12
|
+
counts only what forge saw: survival is followed for parallel integrations
|
|
13
|
+
(`integrate_advanced`), so solo runs and work merged by hand are not here. A row is keyed by
|
|
14
|
+
the reviewer's alias, as the forge-ui Models view is, or by its model when no alias was recorded.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
|
|
23
|
+
from . import connect, dataset_dir
|
|
24
|
+
from .export import SURVIVES_AT, Runs, Store
|
|
25
|
+
from .routing import wilson
|
|
26
|
+
|
|
27
|
+
MIN_LANDED = 10
|
|
28
|
+
HORIZON_DAYS = 14
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _empty(horizon_days):
|
|
32
|
+
return dict(horizon_days=horizon_days, min_landed=MIN_LANDED, survives_at=SURVIVES_AT, reviewers=[],
|
|
33
|
+
unattributed=dict(not_passed=0, no_review=0), skipped=dict(lost=0, no_additions=0),
|
|
34
|
+
other_horizons=[])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def report(runs: Runs, *, horizon_days: int = HORIZON_DAYS, repo: str = None) -> dict:
|
|
38
|
+
out = _empty(horizon_days)
|
|
39
|
+
paths = runs.store.repo_paths
|
|
40
|
+
# Which attempts reviewed each commit: a QA-only resume or a combined task can share one.
|
|
41
|
+
attempts_of = defaultdict(list)
|
|
42
|
+
for key, commit in runs.reviewed.items():
|
|
43
|
+
attempts_of[commit].append(key)
|
|
44
|
+
outcomes, horizons = {}, set()
|
|
45
|
+
for e in runs.store.of('outcome'):
|
|
46
|
+
d = e['d']
|
|
47
|
+
if d.get('name') != 'survival' or not d.get('reviewed'):
|
|
48
|
+
continue
|
|
49
|
+
if repo and paths.get(e['repo']) != repo:
|
|
50
|
+
continue
|
|
51
|
+
if d.get('horizon_days') != horizon_days:
|
|
52
|
+
horizons.add(d.get('horizon_days'))
|
|
53
|
+
continue
|
|
54
|
+
outcomes.setdefault(d['reviewed'], d) # one outcome per commit and horizon
|
|
55
|
+
out['other_horizons'] = sorted(h for h in horizons if isinstance(h, int))
|
|
56
|
+
rows = {}
|
|
57
|
+
for commit, d in sorted(outcomes.items()):
|
|
58
|
+
if d.get('status') == 'lost':
|
|
59
|
+
out['skipped']['lost'] += 1
|
|
60
|
+
continue
|
|
61
|
+
if d.get('status') != 'ok' or not d.get('added'):
|
|
62
|
+
out['skipped']['no_additions'] += 1
|
|
63
|
+
continue
|
|
64
|
+
reviews = []
|
|
65
|
+
for run, task, attempt in attempts_of.get(commit, ()):
|
|
66
|
+
reviews += [r for r in runs.dispatches.get((run, task, 'qa'), ()) if r['attempt'] == attempt]
|
|
67
|
+
passed = {}
|
|
68
|
+
for r in sorted(reviews, key=lambda r: r['ts']):
|
|
69
|
+
name = r['d'].get('alias') or r['d'].get('model')
|
|
70
|
+
if r['d'].get('verdict') == 'PASS' and name:
|
|
71
|
+
passed[name] = r['d']
|
|
72
|
+
if not passed:
|
|
73
|
+
# Reviewed and FAILed, then accepted by the severity gate or a human; or no review recorded.
|
|
74
|
+
out['unattributed']['not_passed' if reviews else 'no_review'] += 1
|
|
75
|
+
continue
|
|
76
|
+
reverted = bool(d.get('reverted_by'))
|
|
77
|
+
rewritten = not reverted and d['surviving'] / d['added'] < SURVIVES_AT
|
|
78
|
+
for name, r in passed.items():
|
|
79
|
+
row = rows.setdefault(name, dict(reviewer=name, specs=set(), landed=0, reverted=0, rewritten=0))
|
|
80
|
+
spec = ':'.join(x for x in (r.get('model'), r.get('effort')) if x)
|
|
81
|
+
if spec:
|
|
82
|
+
row['specs'].add(spec)
|
|
83
|
+
row['landed'] += 1
|
|
84
|
+
row['reverted'] += reverted
|
|
85
|
+
row['rewritten'] += rewritten
|
|
86
|
+
for row in rows.values():
|
|
87
|
+
row['specs'] = sorted(row['specs'])
|
|
88
|
+
row['did_not_hold'] = row['reverted'] + row['rewritten']
|
|
89
|
+
row['enough'] = row['landed'] >= MIN_LANDED
|
|
90
|
+
if row['enough']:
|
|
91
|
+
row['rate'] = row['did_not_hold'] / row['landed']
|
|
92
|
+
row['ci'] = list(wilson(row['did_not_hold'], row['landed']))
|
|
93
|
+
else:
|
|
94
|
+
row['rate'], row['ci'] = None, None
|
|
95
|
+
row['note'] = 'not enough data (%d/%d)' % (row['landed'], MIN_LANDED)
|
|
96
|
+
out['reviewers'] = sorted(rows.values(), key=lambda r: (not r['enough'], -r['landed'], r['reviewer']))
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load(horizon_days: int = HORIZON_DAYS, repo: str = None) -> dict:
|
|
101
|
+
# Reading must not create a store: a fresh machine (or forge-ui asking) gets an empty report.
|
|
102
|
+
if not (dataset_dir() / 'dataset.sqlite').exists():
|
|
103
|
+
return _empty(horizon_days)
|
|
104
|
+
db = connect()
|
|
105
|
+
try:
|
|
106
|
+
runs = Runs(Store(db))
|
|
107
|
+
finally:
|
|
108
|
+
db.close()
|
|
109
|
+
return report(runs, horizon_days=horizon_days, repo=repo)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pct(v):
|
|
113
|
+
return '%.0f%%' % (v * 100)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(args) -> int:
|
|
117
|
+
if args.horizon_days < 1:
|
|
118
|
+
print('forge dataset reviewers: --horizon-days must be at least 1', file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
rep = load(args.horizon_days, os.path.realpath(args.repo) if args.repo else None)
|
|
121
|
+
if args.json:
|
|
122
|
+
print(json.dumps(rep, indent=2, sort_keys=True))
|
|
123
|
+
return 0
|
|
124
|
+
print('reviewers: PASSed work that did not hold up %d days after integrate' % rep['horizon_days'])
|
|
125
|
+
print(' (reverted, or under %d%% of its added lines kept; advisory only, routing never reads it)'
|
|
126
|
+
% (SURVIVES_AT * 100))
|
|
127
|
+
if not rep['reviewers']:
|
|
128
|
+
print(' no PASSed work with a %d-day outcome yet; `forge dataset outcomes --horizon-days %d` records them'
|
|
129
|
+
% (rep['horizon_days'], rep['horizon_days']))
|
|
130
|
+
else:
|
|
131
|
+
print(' %-24s %6s %8s %9s %s' % ('reviewer', 'landed', 'reverted', 'rewritten', 'did not hold'))
|
|
132
|
+
for r in rep['reviewers']:
|
|
133
|
+
held = r['note'] if not r['enough'] else '%s [%s-%s] (%d/%d)' % (
|
|
134
|
+
_pct(r['rate']), _pct(r['ci'][0]), _pct(r['ci'][1]), r['did_not_hold'], r['landed'])
|
|
135
|
+
print(' %-24s %6d %8d %9d %s' % (r['reviewer'], r['landed'], r['reverted'], r['rewritten'], held))
|
|
136
|
+
u, s = rep['unattributed'], rep['skipped']
|
|
137
|
+
if u['not_passed'] or u['no_review']:
|
|
138
|
+
print(' not counted for any reviewer: %d landed after a FAIL (accepted by the gate or by hand), %d with no review'
|
|
139
|
+
% (u['not_passed'], u['no_review']))
|
|
140
|
+
if s['lost'] or s['no_additions']:
|
|
141
|
+
print(' skipped: %d lost (history rewritten past the integration), %d added no lines' % (s['lost'], s['no_additions']))
|
|
142
|
+
if rep['other_horizons']:
|
|
143
|
+
print(' outcomes also recorded at %s days: pass --horizon-days to see them'
|
|
144
|
+
% ', '.join(str(h) for h in rep['other_horizons']))
|
|
145
|
+
return 0
|
|
@@ -107,6 +107,7 @@ def tasks(runs: Runs, *, repo=None, since=None) -> list:
|
|
|
107
107
|
out.append(dict(
|
|
108
108
|
run=run, task=tid, tier=row.get('declared_difficulty') or row.get('difficulty'),
|
|
109
109
|
ran=first['events'][0]['d'].get('dwarf') if first else None, model=model,
|
|
110
|
+
model_id=resolved.get('model'), effort=resolved.get('effort'),
|
|
110
111
|
first_pass=first['outcome'] if first else None,
|
|
111
112
|
censored=(first['reason'] if first and first['outcome'] is None else (None if first else 'not_run')),
|
|
112
113
|
passed=any(a['outcome'] for a in attempts) or (run, tid) in runs.accepted,
|
|
@@ -149,9 +149,13 @@ _runtime_spec.loader.exec_module(_runtime)
|
|
|
149
149
|
|
|
150
150
|
|
|
151
151
|
def config_path() -> Path:
|
|
152
|
-
# An empty XDG_CONFIG_HOME counts as unset (XDG spec): taken literally it put
|
|
153
|
-
# below the current directory.
|
|
154
|
-
|
|
152
|
+
# An empty or relative XDG_CONFIG_HOME counts as unset (XDG spec): taken literally it put
|
|
153
|
+
# jev.json below the current directory. Same rule as forge_registry.registry_dir().
|
|
154
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
155
|
+
base = Path(value).expanduser() if value else None
|
|
156
|
+
if base is None or not base.is_absolute():
|
|
157
|
+
base = Path.home() / '.config'
|
|
158
|
+
return base / 'forge/jev.json'
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
def load_config() -> dict:
|
|
@@ -5,6 +5,17 @@ run-dir path per line, with a ``#`` header on a new file. ``forge jev``, ``forge
|
|
|
5
5
|
dataset import --registry`` and the forge-ui launcher all use it, so it does not
|
|
6
6
|
live inside the Jev package.
|
|
7
7
|
|
|
8
|
+
Two writers change those files: ``register_run_dir`` (every runner, on every start) and
|
|
9
|
+
``unregister_run_dir`` (``forge runs rm``). Both take an flock on ``<registry dir>/.lock``,
|
|
10
|
+
a stable sibling file that is never replaced. Register never blocks: it waits at most
|
|
11
|
+
``REGISTER_LOCK_WAIT_S`` for the lock and then appends anyway, because a rare duplicate
|
|
12
|
+
line is harmless and a lost run is not. An unlocked append can then land in the file an
|
|
13
|
+
unregister has read and is about to replace, so the two meet halfway: unregister, once it
|
|
14
|
+
has replaced a file, copies onto the new one whatever was appended to the old one after
|
|
15
|
+
its read, and register, once it has appended, checks that the file it wrote is still the
|
|
16
|
+
registry and appends again to the new one if it is not. Either the append reached the old
|
|
17
|
+
file before unregister looked at it again, or register sees the replacement.
|
|
18
|
+
|
|
8
19
|
Run as a script, ``forge_registry.py register <run-dir>`` records a run dir from the
|
|
9
20
|
shell runners (forge-ui-options.sh ``forge_ui_register``): every plan, run and solo run
|
|
10
21
|
is registered, not only the ones opened with ``--ui``, because forge-ui's map of a
|
|
@@ -12,26 +23,45 @@ repo's runs reads this registry. ``FORGE_RUN_REGISTRY=off`` turns that off.
|
|
|
12
23
|
"""
|
|
13
24
|
from __future__ import annotations
|
|
14
25
|
|
|
26
|
+
import contextlib
|
|
27
|
+
import errno
|
|
28
|
+
import fcntl
|
|
15
29
|
import hashlib
|
|
16
30
|
import os
|
|
17
31
|
import stat
|
|
18
32
|
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import time
|
|
19
35
|
from pathlib import Path
|
|
20
36
|
|
|
37
|
+
# register_run_dir runs before forge ui open's own timeouts: it waits this long for the lock,
|
|
38
|
+
# then appends without it. unregister_run_dir is only ever called by `forge runs rm`, which
|
|
39
|
+
# can afford to wait for a busy lock, but not for ever.
|
|
40
|
+
REGISTER_LOCK_WAIT_S = 0.5
|
|
41
|
+
UNREGISTER_LOCK_WAIT_S = 10.0
|
|
42
|
+
# How many times an unlocked register appends again after an unregister replaced the file
|
|
43
|
+
# under it. One is enough unless unregisters keep finishing back to back.
|
|
44
|
+
REGISTER_TRIES = 5
|
|
45
|
+
LOCK_NAME = '.lock'
|
|
46
|
+
|
|
21
47
|
|
|
22
48
|
def registry_dir() -> Path:
|
|
23
49
|
"""The ``runs`` directory under forge's config home.
|
|
24
50
|
|
|
25
51
|
Resolved the same way ``forge_jev.config_path()`` resolves ``jev.json``:
|
|
26
|
-
``XDG_CONFIG_HOME`` when
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise
|
|
53
|
+
``~/.config``. An empty or relative value counts as unset, as the XDG spec says
|
|
54
|
+
("If an implementation encounters a relative path in any of these variables it
|
|
55
|
+
should consider the path invalid and ignore it") and as forge-ui's run list reads
|
|
56
|
+
it; taken literally it named a directory below the current one, usually the
|
|
57
|
+
user's repo. ``config_path()`` is ``<that>/forge/jev.json``; the registry is the
|
|
58
|
+
sibling ``runs`` directory.
|
|
31
59
|
"""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
61
|
+
base = Path(value).expanduser() if value else None
|
|
62
|
+
if base is None or not base.is_absolute():
|
|
63
|
+
base = Path.home() / '.config'
|
|
64
|
+
return base / 'forge' / 'runs'
|
|
35
65
|
|
|
36
66
|
|
|
37
67
|
def repo_key(repo) -> str:
|
|
@@ -51,40 +81,167 @@ def runs_registry(repo) -> Path:
|
|
|
51
81
|
return registry_dir() / (repo_key(repo) + '.txt')
|
|
52
82
|
|
|
53
83
|
|
|
84
|
+
@contextlib.contextmanager
|
|
85
|
+
def _registry_lock(wait_s: float):
|
|
86
|
+
"""Hold an exclusive flock on ``<registry dir>/.lock`` for the ``with`` body.
|
|
87
|
+
|
|
88
|
+
The lock is on a file of its own and never on a registry file, because unregister
|
|
89
|
+
replaces those: a lock on a file that is renamed over locks nothing. Polls without
|
|
90
|
+
blocking for up to ``wait_s`` seconds, then raises BlockingIOError; raises OSError if
|
|
91
|
+
the lock file cannot be opened or is not a regular file (a FIFO would block a reader).
|
|
92
|
+
A filesystem that cannot flock at all (ENOLCK, EOPNOTSUPP) gets no locking, as it
|
|
93
|
+
would anywhere else in forge, rather than a registry nobody can change.
|
|
94
|
+
"""
|
|
95
|
+
fd = os.open(registry_dir() / LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
96
|
+
try:
|
|
97
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
98
|
+
raise OSError(errno.EINVAL, 'the registry lock is not a regular file', LOCK_NAME)
|
|
99
|
+
deadline = time.monotonic() + wait_s
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
103
|
+
break
|
|
104
|
+
except OSError as error:
|
|
105
|
+
if error.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
106
|
+
break
|
|
107
|
+
if time.monotonic() >= deadline:
|
|
108
|
+
raise BlockingIOError(errno.EWOULDBLOCK, 'the run registry is locked', LOCK_NAME) from error
|
|
109
|
+
time.sleep(0.01)
|
|
110
|
+
yield
|
|
111
|
+
finally:
|
|
112
|
+
os.close(fd)
|
|
113
|
+
|
|
114
|
+
|
|
54
115
|
def register_run_dir(repo: Path, run_dir: Path) -> None:
|
|
55
116
|
"""Record an absolute run-dir path for this repo, deduplicated.
|
|
56
117
|
|
|
57
118
|
Never raises and never blocks: failing to note where a run lives must not fail the run,
|
|
58
|
-
and ``forge ui open`` calls this before any of its own timeouts.
|
|
119
|
+
and ``forge ui open`` calls this before any of its own timeouts. The registry lock is
|
|
120
|
+
tried for at most ``REGISTER_LOCK_WAIT_S``; if it stays busy the line is appended without
|
|
121
|
+
it (see the module docstring), and appended again if an unregister replaced the file
|
|
122
|
+
it went to.
|
|
59
123
|
"""
|
|
60
124
|
try:
|
|
61
|
-
# A relative
|
|
62
|
-
#
|
|
125
|
+
# A relative HOME (registry_dir already ignores a relative XDG_CONFIG_HOME) would resolve
|
|
126
|
+
# below the current directory -- for a runner, usually the user's repo. Forge does not
|
|
127
|
+
# write into the user's tree.
|
|
63
128
|
if not registry_dir().is_absolute():
|
|
64
129
|
return
|
|
65
130
|
registry = runs_registry(repo)
|
|
66
131
|
registry.parent.mkdir(parents=True, exist_ok=True)
|
|
67
132
|
line = str(Path(run_dir).resolve())
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
with contextlib.ExitStack() as held:
|
|
134
|
+
try:
|
|
135
|
+
held.enter_context(_registry_lock(REGISTER_LOCK_WAIT_S))
|
|
136
|
+
except OSError:
|
|
137
|
+
pass # busy past the wait, or no lock file to be had: append unlocked
|
|
138
|
+
for _ in range(REGISTER_TRIES):
|
|
139
|
+
existing = []
|
|
140
|
+
if registry.is_file():
|
|
141
|
+
existing = [x.strip() for x in registry.read_text(errors='replace').splitlines()]
|
|
142
|
+
if line in existing:
|
|
143
|
+
return
|
|
144
|
+
header = [] if existing else ['# forge run dirs for ' + str(Path(repo).resolve())]
|
|
145
|
+
# O_NONBLOCK: a FIFO with no reader fails at once (ENXIO) instead of blocking the
|
|
146
|
+
# open, and anything that is not a regular file is left alone.
|
|
147
|
+
fd = os.open(registry, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK, 0o644)
|
|
148
|
+
with os.fdopen(fd, 'a') as handle:
|
|
149
|
+
written = os.fstat(fd)
|
|
150
|
+
if not stat.S_ISREG(written.st_mode):
|
|
151
|
+
return
|
|
152
|
+
handle.write(''.join(entry + '\n' for entry in header + [line]))
|
|
153
|
+
handle.flush()
|
|
154
|
+
# Still the registry: the line is in it, or in the old file an unregister copies
|
|
155
|
+
# from once it has replaced it. Replaced already: the line may be in neither.
|
|
156
|
+
try:
|
|
157
|
+
current = os.lstat(registry)
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
continue
|
|
160
|
+
if (current.st_dev, current.st_ino) == (written.st_dev, written.st_ino):
|
|
161
|
+
return
|
|
83
162
|
except (OSError, ValueError, RuntimeError):
|
|
84
163
|
# ValueError: a NUL byte in a path; RuntimeError: a symlink loop (pathlib before 3.13)
|
|
85
164
|
pass
|
|
86
165
|
|
|
87
166
|
|
|
167
|
+
def _rewrite_without(path: Path, wanted: set) -> bool:
|
|
168
|
+
"""Atomically drop the lines of one registry file that name a path in ``wanted``.
|
|
169
|
+
|
|
170
|
+
Every other byte is kept as it was. Returns whether the file changed. A registry file
|
|
171
|
+
that is not a regular file is skipped, and read without blocking.
|
|
172
|
+
|
|
173
|
+
The caller holds the registry lock, but a register that gave up waiting for it appends
|
|
174
|
+
anyway, possibly to this file after it was read. So the old file stays open across the
|
|
175
|
+
replace, and whatever it gained since the read is appended to the new one.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
|
179
|
+
except OSError as error:
|
|
180
|
+
if error.errno in (errno.ENOENT, errno.ELOOP):
|
|
181
|
+
return False
|
|
182
|
+
raise
|
|
183
|
+
with os.fdopen(fd, 'rb') as old:
|
|
184
|
+
info = os.fstat(fd)
|
|
185
|
+
if not stat.S_ISREG(info.st_mode):
|
|
186
|
+
return False
|
|
187
|
+
data = old.read()
|
|
188
|
+
lines = data.splitlines(keepends=True)
|
|
189
|
+
kept = [line for line in lines if os.fsdecode(line).strip() not in wanted]
|
|
190
|
+
if len(kept) == len(lines):
|
|
191
|
+
return False
|
|
192
|
+
content = b''.join(kept)
|
|
193
|
+
temporary = None
|
|
194
|
+
try:
|
|
195
|
+
out, temporary = tempfile.mkstemp(dir=str(path.parent), prefix='.' + path.name + '.', suffix='.tmp')
|
|
196
|
+
with os.fdopen(out, 'wb') as handle:
|
|
197
|
+
handle.write(content)
|
|
198
|
+
handle.flush()
|
|
199
|
+
os.fsync(handle.fileno())
|
|
200
|
+
os.chmod(temporary, stat.S_IMODE(info.st_mode))
|
|
201
|
+
os.replace(temporary, path)
|
|
202
|
+
temporary = None
|
|
203
|
+
finally:
|
|
204
|
+
if temporary is not None:
|
|
205
|
+
try:
|
|
206
|
+
os.unlink(temporary)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
# An append that comes later still is register's to notice: the path no longer
|
|
210
|
+
# names the file it wrote.
|
|
211
|
+
late = old.read()
|
|
212
|
+
if late:
|
|
213
|
+
extra = [line for line in late.splitlines(keepends=True) if os.fsdecode(line).strip() not in wanted]
|
|
214
|
+
if extra:
|
|
215
|
+
if content and not content.endswith(b'\n'):
|
|
216
|
+
extra.insert(0, b'\n')
|
|
217
|
+
out = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
218
|
+
with os.fdopen(out, 'ab') as handle:
|
|
219
|
+
handle.write(b''.join(extra))
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def unregister_run_dir(run_dir) -> list:
|
|
224
|
+
"""Remove a run dir from every registry file that lists it; return the files rewritten.
|
|
225
|
+
|
|
226
|
+
The reverse of ``register_run_dir``, for ``forge runs rm``, so unlike it this reports
|
|
227
|
+
failure: it raises OSError (BlockingIOError when the registry lock stays busy) and the
|
|
228
|
+
caller says the registry needs repair. Each file is rewritten atomically under the
|
|
229
|
+
registry lock, so a concurrent register loses nothing. ``run_dir`` need not exist any
|
|
230
|
+
more: entries were written resolved, so the path is resolved the same way, and the
|
|
231
|
+
path as given is matched too.
|
|
232
|
+
"""
|
|
233
|
+
directory = registry_dir()
|
|
234
|
+
if not directory.is_absolute() or not directory.is_dir():
|
|
235
|
+
return []
|
|
236
|
+
wanted = {os.path.realpath(run_dir), os.path.abspath(run_dir)}
|
|
237
|
+
changed = []
|
|
238
|
+
with _registry_lock(UNREGISTER_LOCK_WAIT_S):
|
|
239
|
+
for path in sorted(directory.glob('*.txt')):
|
|
240
|
+
if _rewrite_without(path, wanted):
|
|
241
|
+
changed.append(path)
|
|
242
|
+
return changed
|
|
243
|
+
|
|
244
|
+
|
|
88
245
|
# A path, not a file's worth of data: the first line of <run-dir>/repo is all that is read.
|
|
89
246
|
_REPO_LINE_MAX = 64 * 1024
|
|
90
247
|
|
package/ui/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# forge-ui
|
|
2
2
|
|
|
3
|
-
forge-ui is a small
|
|
3
|
+
forge-ui is a small local web UI for forge runs. It reads everything, and changes things only by
|
|
4
|
+
calling forge's own commands (see [Controls](#controls)). It draws them the way
|
|
4
5
|
[archivision](https://github.com/Giamme/archivision) draws a codebase: a 3D isometric map of
|
|
5
6
|
blocks on a plate, with cables between them and lamps that light up as things happen.
|
|
6
7
|
|
|
@@ -95,7 +96,7 @@ Sources are checked in this order:
|
|
|
95
96
|
2. `--root DIR` itself, its children and grandchildren. Scanning stops when a plan
|
|
96
97
|
directory is found; hidden children and symlinked child directories are skipped.
|
|
97
98
|
3. Forge's registry: `*.txt` files in `$XDG_CONFIG_HOME/forge/runs/`, defaulting
|
|
98
|
-
to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset or
|
|
99
|
+
to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset, empty or relative. Each file
|
|
99
100
|
supplies absolute paths, one per line; blank lines and `#` comments are ignored.
|
|
100
101
|
`--registry-dir` replaces this location; `--no-registry` disables it.
|
|
101
102
|
|
|
@@ -188,8 +189,9 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
|
|
|
188
189
|
- **Models (`#/models`):** a leaderboard of the models forge used. Implementers: tasks, first-attempt
|
|
189
190
|
pass rate with its 95% Wilson interval, censored attempts, retries, escalations away from the
|
|
190
191
|
model, timeouts, gate and human accepts, median model time and tokens. Reviewers: primary,
|
|
191
|
-
shadow and final reviews, the PASS/FAIL split, findings per review by severity,
|
|
192
|
-
shadow review agreed with the primary
|
|
192
|
+
shadow and final reviews, the PASS/FAIL split, findings per review by severity, how often a
|
|
193
|
+
shadow review agreed with the primary, and how often work it PASSed did not hold (later
|
|
194
|
+
reverted or rewritten, from `forge dataset reviewers`; "not enough data" below 10 outcomes). Filter by project, difficulty, kind and period, or keep
|
|
193
195
|
only exploratory picks; open a difficulty for its own table. Routing is not random, so the
|
|
194
196
|
numbers compare what happened, not what would have: the page says so. A model's name opens the
|
|
195
197
|
run cloud filtered to its runs.
|
|
@@ -223,7 +225,7 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
|
|
|
223
225
|
carries the same tree floating above it. A node (`…/tasks/<id>/fractal/<node>`) shows its goal,
|
|
224
226
|
paths, timings, cost and tokens, its decomposition and the children it admitted, its last
|
|
225
227
|
messages, and its candidate diff. Fractal's state is read under `$XDG_STATE_HOME/forge/fractal`
|
|
226
|
-
(default `~/.local/state`), read
|
|
228
|
+
(default `~/.local/state`), only read, without following symlinks.
|
|
227
229
|
- **Export:** *Export…* in a run's header downloads the run as one HTML file that opens in any
|
|
228
230
|
browser without forge-ui, offline included (in 2D; in 3D when cdn.jsdelivr.net is reachable).
|
|
229
231
|
It asks each time: by default prompts, reviewer reports, logs, finding details and diffs are
|
|
@@ -252,6 +254,37 @@ machine while this server is running and discovering that run dir. *Live refresh
|
|
|
252
254
|
polling; a hidden tab polls only the run list, every 30 seconds, and only with browser
|
|
253
255
|
notifications on. Failed requests keep the last good data and show a banner.
|
|
254
256
|
|
|
257
|
+
## Controls
|
|
258
|
+
|
|
259
|
+
The page can change a run, and every change is one call of a forge CLI; the server writes
|
|
260
|
+
nothing itself, so a change is validated, locked and logged exactly as if typed in a terminal
|
|
261
|
+
(`control.log` records `ui` as the actor). Each change is confirmed first and says when it
|
|
262
|
+
takes effect. The routes are in [docs/api.md](docs/api.md).
|
|
263
|
+
|
|
264
|
+
- **Run and task controls (`forge control`):** in a plan's inspector, the QA threshold,
|
|
265
|
+
timeout, escalate-after and verify retries, and while a scheduler is live the retry and
|
|
266
|
+
infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
|
|
267
|
+
exempts it from the run's threshold), its own retry budget and a **model pin**: the model
|
|
268
|
+
its next full retry uses, once. A control that cannot apply (no live scheduler, a merged
|
|
269
|
+
task, a Fractal plan's pin) is disabled and says why.
|
|
270
|
+
- **Open findings (`forge issues`):** the findings a run, or every run of its repository,
|
|
271
|
+
left open. The list opens from a quick offline listing and then fills in what is already
|
|
272
|
+
filed; select findings to file as GitHub issues, or dismiss them (and undo that).
|
|
273
|
+
- **Delete a run (`forge runs rm`):** a dry run first, then the deletion. Its traces stay in
|
|
274
|
+
the dataset; an archive is written unless you decline it; `worktrees` and `branches` remove
|
|
275
|
+
the run's clean worktrees and its branches that nothing has checked out. Foreign or dirty
|
|
276
|
+
worktrees are listed and left.
|
|
277
|
+
- **Settings (`forge config`):** the registry, Jev, the repository's `.forge` files, the
|
|
278
|
+
dataset and `FORGE_*` variables (secrets shown as `<set>`), with edits for `repo.verify`,
|
|
279
|
+
`repo.setup`, `repo.verify-subset` and `jev.enabled`.
|
|
280
|
+
|
|
281
|
+
**Protection.** Each server start makes a random token and serves it in the page; every change
|
|
282
|
+
must send it, from a `Host` of `127.0.0.1` or `localhost`, with an `Origin` equal to the host
|
|
283
|
+
when one is sent, as JSON of at most 64 KiB, or it is refused before any CLI runs. There are no
|
|
284
|
+
CORS headers. One write per run at a time (`409 busy`). `--read-only` or `FORGE_UI_READONLY=1`
|
|
285
|
+
turns all of it off: no token is issued, every change is a 405, and the controls say the page cannot change the run.
|
|
286
|
+
`forge ui open` never reuses a server running in the other mode.
|
|
287
|
+
|
|
255
288
|
## Layout of the code
|
|
256
289
|
|
|
257
290
|
- `forge_ui/reader.py`: discover runs, parse their files and produce summaries and details.
|
|
@@ -335,8 +368,9 @@ python3 tools/readme_media.py # writes docs/media/
|
|
|
335
368
|
## Privacy
|
|
336
369
|
|
|
337
370
|
Run dirs can contain prompts, diffs and review text from private repos. The server binds
|
|
338
|
-
only `127.0.0.1
|
|
339
|
-
|
|
371
|
+
only `127.0.0.1` and makes no network requests. It writes nothing itself; a change you
|
|
372
|
+
confirmed in the page is a call of forge's own CLI, protected by a per-launch token
|
|
373
|
+
(`--read-only` turns that off). Outside the runs it reads one kind of file: for a claude execution whose log
|
|
340
374
|
holds only its final answer, that run's session file, `<session id>.jsonl` under
|
|
341
375
|
`~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects/`). Anyone with access to the local server can read the runs it discovers, and
|
|
342
376
|
forge now registers every run, so that is every run on the machine.
|