@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/SKILL.md +22 -3
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +28 -2
  5. package/docs/commands.md +55 -0
  6. package/docs/dataset.md +22 -0
  7. package/docs/decomposed-runs.md +54 -2
  8. package/docs/forge-ui.md +48 -7
  9. package/docs/memory.md +4 -2
  10. package/docs/reviews.md +87 -2
  11. package/forge +31 -1
  12. package/package.json +1 -1
  13. package/references/dataset.md +103 -4
  14. package/references/decompose.md +41 -5
  15. package/references/harnesses.md +43 -1
  16. package/references/ui.md +31 -6
  17. package/scripts/forge-config.py +350 -0
  18. package/scripts/forge-control.py +649 -0
  19. package/scripts/forge-dispatch.sh +235 -121
  20. package/scripts/forge-issues.py +1134 -0
  21. package/scripts/forge-lib-plans.sh +6 -2
  22. package/scripts/forge-lib-review.sh +158 -3
  23. package/scripts/forge-lib-task.sh +358 -41
  24. package/scripts/forge-parallel-options.sh +3 -1
  25. package/scripts/forge-parallel.sh +99 -13
  26. package/scripts/forge-prompt.py +61 -4
  27. package/scripts/forge-route.py +245 -28
  28. package/scripts/forge-runs-rm.py +840 -0
  29. package/scripts/forge-runs.py +19 -1
  30. package/scripts/forge-runtime.py +47 -1
  31. package/scripts/forge-schedule.py +227 -11
  32. package/scripts/forge-ui.py +85 -19
  33. package/scripts/forge_dataset/__init__.py +84 -8
  34. package/scripts/forge_dataset/capture.py +5 -2
  35. package/scripts/forge_dataset/cli.py +44 -1
  36. package/scripts/forge_dataset/export.py +36 -3
  37. package/scripts/forge_dataset/forecast.py +355 -0
  38. package/scripts/forge_dataset/harvest.py +45 -2
  39. package/scripts/forge_dataset/importer.py +431 -17
  40. package/scripts/forge_dataset/reviewers.py +145 -0
  41. package/scripts/forge_dataset/routing.py +1 -0
  42. package/scripts/forge_jev/__init__.py +7 -3
  43. package/scripts/forge_registry.py +183 -26
  44. package/ui/README.md +41 -7
  45. package/ui/docs/api.md +259 -21
  46. package/ui/forge_ui/__main__.py +10 -2
  47. package/ui/forge_ui/control.py +587 -0
  48. package/ui/forge_ui/export.py +17 -3
  49. package/ui/forge_ui/reader.py +210 -12
  50. package/ui/forge_ui/server.py +125 -4
  51. package/ui/forge_ui/static/app.js +104 -18
  52. package/ui/forge_ui/static/cloud3d.js +4 -1
  53. package/ui/forge_ui/static/cloudmodel.js +1 -1
  54. package/ui/forge_ui/static/cloudui.js +13 -4
  55. package/ui/forge_ui/static/controls.js +619 -0
  56. package/ui/forge_ui/static/findingsview.js +438 -0
  57. package/ui/forge_ui/static/graph.js +6 -5
  58. package/ui/forge_ui/static/index.html +7 -1
  59. package/ui/forge_ui/static/leaderboard.js +18 -0
  60. package/ui/forge_ui/static/modelstats.js +19 -1
  61. package/ui/forge_ui/static/panel.js +76 -14
  62. package/ui/forge_ui/static/rundelete.js +388 -0
  63. package/ui/forge_ui/static/runmodel.js +128 -13
  64. package/ui/forge_ui/static/scene3d.js +32 -15
  65. package/ui/forge_ui/static/settings.js +399 -0
  66. package/ui/forge_ui/static/style.css +184 -5
  67. 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 jev.json
153
- # below the current directory.
154
- return Path(os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')).expanduser() / 'forge/jev.json'
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 set and not empty, otherwise ``~/.config``, then
27
- ``expanduser()``. An empty value counts as unset, as the XDG spec says and as
28
- forge-ui's run list reads it; taken literally it named a directory below the
29
- current one, usually the user's repo. ``config_path()`` is
30
- ``<that>/forge/jev.json``; the registry is the sibling ``runs`` directory.
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
- config = (Path(os.environ.get('XDG_CONFIG_HOME') or str(Path.home() / '.config')).expanduser()
33
- / 'forge/jev.json')
34
- return config.parent / 'runs'
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 XDG_CONFIG_HOME resolves below the current directory -- for a runner or
62
- # `forge ui open`, usually the user's repo. Forge does not write into the user's tree.
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
- existing = []
69
- if registry.is_file():
70
- existing = [x.strip() for x in registry.read_text(errors='replace').splitlines()]
71
- if line in existing:
72
- return
73
- header = [] if existing else ['# forge run dirs for ' + str(Path(repo).resolve())]
74
- # O_NONBLOCK: a FIFO with no reader fails at once (ENXIO) instead of blocking the
75
- # open, and anything that is not a regular file is left alone.
76
- fd = os.open(registry, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK, 0o644)
77
- with os.fdopen(fd, 'a') as handle:
78
- if not stat.S_ISREG(os.fstat(fd).st_mode):
79
- return
80
- for entry in header:
81
- handle.write(entry + '\n')
82
- handle.write(line + '\n')
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, read-only local web UI for forge runs. It draws them the way
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 empty. Each file
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, and how often a
192
- shadow review agreed with the primary. Filter by project, difficulty, kind and period, or keep
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-only, without following symlinks.
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`, makes no network requests, and writes nothing to run dirs, repositories or
339
- forge's config. Outside the runs it reads one kind of file: for a claude execution whose log
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.