@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
@@ -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,12 +23,27 @@ 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.
@@ -51,11 +77,45 @@ def runs_registry(repo) -> Path:
51
77
  return registry_dir() / (repo_key(repo) + '.txt')
52
78
 
53
79
 
80
+ @contextlib.contextmanager
81
+ def _registry_lock(wait_s: float):
82
+ """Hold an exclusive flock on ``<registry dir>/.lock`` for the ``with`` body.
83
+
84
+ The lock is on a file of its own and never on a registry file, because unregister
85
+ replaces those: a lock on a file that is renamed over locks nothing. Polls without
86
+ blocking for up to ``wait_s`` seconds, then raises BlockingIOError; raises OSError if
87
+ the lock file cannot be opened or is not a regular file (a FIFO would block a reader).
88
+ A filesystem that cannot flock at all (ENOLCK, EOPNOTSUPP) gets no locking, as it
89
+ would anywhere else in forge, rather than a registry nobody can change.
90
+ """
91
+ fd = os.open(registry_dir() / LOCK_NAME, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
92
+ try:
93
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
94
+ raise OSError(errno.EINVAL, 'the registry lock is not a regular file', LOCK_NAME)
95
+ deadline = time.monotonic() + wait_s
96
+ while True:
97
+ try:
98
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
99
+ break
100
+ except OSError as error:
101
+ if error.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
102
+ break
103
+ if time.monotonic() >= deadline:
104
+ raise BlockingIOError(errno.EWOULDBLOCK, 'the run registry is locked', LOCK_NAME) from error
105
+ time.sleep(0.01)
106
+ yield
107
+ finally:
108
+ os.close(fd)
109
+
110
+
54
111
  def register_run_dir(repo: Path, run_dir: Path) -> None:
55
112
  """Record an absolute run-dir path for this repo, deduplicated.
56
113
 
57
114
  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.
115
+ and ``forge ui open`` calls this before any of its own timeouts. The registry lock is
116
+ tried for at most ``REGISTER_LOCK_WAIT_S``; if it stays busy the line is appended without
117
+ it (see the module docstring), and appended again if an unregister replaced the file
118
+ it went to.
59
119
  """
60
120
  try:
61
121
  # A relative XDG_CONFIG_HOME resolves below the current directory -- for a runner or
@@ -65,26 +125,118 @@ def register_run_dir(repo: Path, run_dir: Path) -> None:
65
125
  registry = runs_registry(repo)
66
126
  registry.parent.mkdir(parents=True, exist_ok=True)
67
127
  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')
128
+ with contextlib.ExitStack() as held:
129
+ try:
130
+ held.enter_context(_registry_lock(REGISTER_LOCK_WAIT_S))
131
+ except OSError:
132
+ pass # busy past the wait, or no lock file to be had: append unlocked
133
+ for _ in range(REGISTER_TRIES):
134
+ existing = []
135
+ if registry.is_file():
136
+ existing = [x.strip() for x in registry.read_text(errors='replace').splitlines()]
137
+ if line in existing:
138
+ return
139
+ header = [] if existing else ['# forge run dirs for ' + str(Path(repo).resolve())]
140
+ # O_NONBLOCK: a FIFO with no reader fails at once (ENXIO) instead of blocking the
141
+ # open, and anything that is not a regular file is left alone.
142
+ fd = os.open(registry, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NONBLOCK, 0o644)
143
+ with os.fdopen(fd, 'a') as handle:
144
+ written = os.fstat(fd)
145
+ if not stat.S_ISREG(written.st_mode):
146
+ return
147
+ handle.write(''.join(entry + '\n' for entry in header + [line]))
148
+ handle.flush()
149
+ # Still the registry: the line is in it, or in the old file an unregister copies
150
+ # from once it has replaced it. Replaced already: the line may be in neither.
151
+ try:
152
+ current = os.lstat(registry)
153
+ except FileNotFoundError:
154
+ continue
155
+ if (current.st_dev, current.st_ino) == (written.st_dev, written.st_ino):
156
+ return
83
157
  except (OSError, ValueError, RuntimeError):
84
158
  # ValueError: a NUL byte in a path; RuntimeError: a symlink loop (pathlib before 3.13)
85
159
  pass
86
160
 
87
161
 
162
+ def _rewrite_without(path: Path, wanted: set) -> bool:
163
+ """Atomically drop the lines of one registry file that name a path in ``wanted``.
164
+
165
+ Every other byte is kept as it was. Returns whether the file changed. A registry file
166
+ that is not a regular file is skipped, and read without blocking.
167
+
168
+ The caller holds the registry lock, but a register that gave up waiting for it appends
169
+ anyway, possibly to this file after it was read. So the old file stays open across the
170
+ replace, and whatever it gained since the read is appended to the new one.
171
+ """
172
+ try:
173
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
174
+ except OSError as error:
175
+ if error.errno in (errno.ENOENT, errno.ELOOP):
176
+ return False
177
+ raise
178
+ with os.fdopen(fd, 'rb') as old:
179
+ info = os.fstat(fd)
180
+ if not stat.S_ISREG(info.st_mode):
181
+ return False
182
+ data = old.read()
183
+ lines = data.splitlines(keepends=True)
184
+ kept = [line for line in lines if os.fsdecode(line).strip() not in wanted]
185
+ if len(kept) == len(lines):
186
+ return False
187
+ content = b''.join(kept)
188
+ temporary = None
189
+ try:
190
+ out, temporary = tempfile.mkstemp(dir=str(path.parent), prefix='.' + path.name + '.', suffix='.tmp')
191
+ with os.fdopen(out, 'wb') as handle:
192
+ handle.write(content)
193
+ handle.flush()
194
+ os.fsync(handle.fileno())
195
+ os.chmod(temporary, stat.S_IMODE(info.st_mode))
196
+ os.replace(temporary, path)
197
+ temporary = None
198
+ finally:
199
+ if temporary is not None:
200
+ try:
201
+ os.unlink(temporary)
202
+ except OSError:
203
+ pass
204
+ # An append that comes later still is register's to notice: the path no longer
205
+ # names the file it wrote.
206
+ late = old.read()
207
+ if late:
208
+ extra = [line for line in late.splitlines(keepends=True) if os.fsdecode(line).strip() not in wanted]
209
+ if extra:
210
+ if content and not content.endswith(b'\n'):
211
+ extra.insert(0, b'\n')
212
+ out = os.open(path, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW | os.O_NONBLOCK)
213
+ with os.fdopen(out, 'ab') as handle:
214
+ handle.write(b''.join(extra))
215
+ return True
216
+
217
+
218
+ def unregister_run_dir(run_dir) -> list:
219
+ """Remove a run dir from every registry file that lists it; return the files rewritten.
220
+
221
+ The reverse of ``register_run_dir``, for ``forge runs rm``, so unlike it this reports
222
+ failure: it raises OSError (BlockingIOError when the registry lock stays busy) and the
223
+ caller says the registry needs repair. Each file is rewritten atomically under the
224
+ registry lock, so a concurrent register loses nothing. ``run_dir`` need not exist any
225
+ more: entries were written resolved, so the path is resolved the same way, and the
226
+ path as given is matched too.
227
+ """
228
+ directory = registry_dir()
229
+ if not directory.is_absolute() or not directory.is_dir():
230
+ return []
231
+ wanted = {os.path.realpath(run_dir), os.path.abspath(run_dir)}
232
+ changed = []
233
+ with _registry_lock(UNREGISTER_LOCK_WAIT_S):
234
+ for path in sorted(directory.glob('*.txt')):
235
+ if _rewrite_without(path, wanted):
236
+ changed.append(path)
237
+ return changed
238
+
239
+
88
240
  # A path, not a file's worth of data: the first line of <run-dir>/repo is all that is read.
89
241
  _REPO_LINE_MAX = 64 * 1024
90
242
 
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
 
@@ -223,7 +224,7 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
223
224
  carries the same tree floating above it. A node (`…/tasks/<id>/fractal/<node>`) shows its goal,
224
225
  paths, timings, cost and tokens, its decomposition and the children it admitted, its last
225
226
  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.
227
+ (default `~/.local/state`), only read, without following symlinks.
227
228
  - **Export:** *Export…* in a run's header downloads the run as one HTML file that opens in any
228
229
  browser without forge-ui, offline included (in 2D; in 3D when cdn.jsdelivr.net is reachable).
229
230
  It asks each time: by default prompts, reviewer reports, logs, finding details and diffs are
@@ -252,6 +253,37 @@ machine while this server is running and discovering that run dir. *Live refresh
252
253
  polling; a hidden tab polls only the run list, every 30 seconds, and only with browser
253
254
  notifications on. Failed requests keep the last good data and show a banner.
254
255
 
256
+ ## Controls
257
+
258
+ The page can change a run, and every change is one call of a forge CLI; the server writes
259
+ nothing itself, so a change is validated, locked and logged exactly as if typed in a terminal
260
+ (`control.log` records `ui` as the actor). Each change is confirmed first and says when it
261
+ takes effect. The routes are in [docs/api.md](docs/api.md).
262
+
263
+ - **Run and task controls (`forge control`):** in a plan's inspector, the QA threshold,
264
+ timeout, escalate-after and verify retries, and while a scheduler is live the retry and
265
+ infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
266
+ exempts it from the run's threshold), its own retry budget and a **model pin**: the model
267
+ its next full retry uses, once. A control that cannot apply (no live scheduler, a merged
268
+ task, a Fractal plan's pin) is disabled and says why.
269
+ - **Open findings (`forge issues`):** the findings a run, or every run of its repository,
270
+ left open. The list opens from a quick offline listing and then fills in what is already
271
+ filed; select findings to file as GitHub issues, or dismiss them (and undo that).
272
+ - **Delete a run (`forge runs rm`):** a dry run first, then the deletion. Its traces stay in
273
+ the dataset; an archive is written unless you decline it; `worktrees` and `branches` remove
274
+ the run's clean worktrees and its branches that nothing has checked out. Foreign or dirty
275
+ worktrees are listed and left.
276
+ - **Settings (`forge config`):** the registry, Jev, the repository's `.forge` files, the
277
+ dataset and `FORGE_*` variables (secrets shown as `<set>`), with edits for `repo.verify`,
278
+ `repo.setup`, `repo.verify-subset` and `jev.enabled`.
279
+
280
+ **Protection.** Each server start makes a random token and serves it in the page; every change
281
+ must send it, from a `Host` of `127.0.0.1` or `localhost`, with an `Origin` equal to the host
282
+ when one is sent, as JSON of at most 64 KiB, or it is refused before any CLI runs. There are no
283
+ CORS headers. One write per run at a time (`409 busy`). `--read-only` or `FORGE_UI_READONLY=1`
284
+ turns all of it off: no token is issued, every change is a 405, and the controls say the page cannot change the run.
285
+ `forge ui open` never reuses a server running in the other mode.
286
+
255
287
  ## Layout of the code
256
288
 
257
289
  - `forge_ui/reader.py`: discover runs, parse their files and produce summaries and details.
@@ -335,8 +367,9 @@ python3 tools/readme_media.py # writes docs/media/
335
367
  ## Privacy
336
368
 
337
369
  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
370
+ only `127.0.0.1` and makes no network requests. It writes nothing itself; a change you
371
+ confirmed in the page is a call of forge's own CLI, protected by a per-launch token
372
+ (`--read-only` turns that off). Outside the runs it reads one kind of file: for a claude execution whose log
340
373
  holds only its final answer, that run's session file, `<session id>.jsonl` under
341
374
  `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR/projects/`). Anyone with access to the local server can read the runs it discovers, and
342
375
  forge now registers every run, so that is every run on the machine.
package/ui/docs/api.md CHANGED
@@ -13,14 +13,16 @@ forge_ui/diffs.py unified diffs: parsing, the ```diff fence of a review pr
13
13
  forge_ui/models.py per-run model facts (attempts and reviews) for the leaderboard, cached per run
14
14
  forge_ui/fractal.py Fractal child tasks under $XDG_STATE_HOME/forge/fractal, read through dir fds
15
15
  forge_ui/transcripts.py an agent CLI's `<role>.log` read as one conversation, whichever harness wrote it
16
- forge_ui/server.py serves those dicts as JSON plus the static page, 127.0.0.1 only
16
+ forge_ui/server.py serves those dicts as JSON plus the static page, 127.0.0.1 only; authenticates writes
17
+ forge_ui/control.py the write routes: each one is a forge CLI call (see Controls)
17
18
  forge_ui/static/ the page: index.html, app.js, style.css, graph.js and the scripts they load
18
19
  ```
19
20
 
20
21
  Python 3.9+ standard library only. No third-party packages, no build step. The server makes
21
22
  no network requests; the page loads three.js 0.160.0 from cdn.jsdelivr.net only when the 3D
22
- view is used, and falls back to the 2D graph when it can't. The UI is read-only: nothing in
23
- forge-ui writes to a run dir, a repo or forge's config.
23
+ view is used, and falls back to the 2D graph when it can't. forge-ui never writes to a run dir,
24
+ a repo or forge's config itself: every change is one call of the forge CLI (see Controls (write
25
+ endpoints) below), and `--read-only` removes even that.
24
26
 
25
27
  ## What a forge plan dir looks like
26
28
 
@@ -55,7 +57,7 @@ written), and the reader must never raise because of one.
55
57
  | `tasks/<id>/status` | one word: `RUNNING PASS FAIL ERROR INFRA TIMEOUT UNKNOWN INVALIDATED BLOCKED INTERRUPTED NOCHANGES CONFLICT` |
56
58
  | `tasks/<id>/attempt` | the current attempt number |
57
59
  | `tasks/<id>/attempts.tsv` | `epoch<TAB>attempt<TAB>status<TAB>infra_class` per line, one per attempt entry; status may also be `MERGED`, `infra_class` may be empty, `-` or missing |
58
- | `tasks/<id>/escalations.tsv` | `epoch<TAB>attempt<TAB>from_spec<TAB>to_spec` per line |
60
+ | `tasks/<id>/escalations.tsv` | `epoch<TAB>attempt<TAB>from_spec<TAB>to_spec[<TAB>source]` per line; `source` (who decided: `policy`, a human's `retry --dwarf` as `retry`, or a `pin`) is missing on older rows, which read as `policy` |
59
61
  | `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
60
62
  | `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
61
63
  | `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value` lines in current forge; returned as text) |
@@ -178,8 +180,11 @@ class Catalog:
178
180
  fractal(self, key, task_id),
179
181
  fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
180
182
  def models(self) -> dict # Models, over every run
183
+ def locate(self, key) -> tuple[str, str] | None # (run dir, 'plan' | 'solo'); None for an unknown key
181
184
  ```
182
185
 
186
+ `Catalog.locate` is how a write finds its run: a client names a run by key and never by path.
187
+
183
188
  `Catalog` re-discovers and re-reads on every call: runs change while they are watched. Files
184
189
  that stop changing once written (`metrics.json`, `dataset.json`, `*.resolved` and `qa.last`
185
190
  copies, findings of a reply) are parsed once and kept by `(dev, inode, size, mtime_ns)`; a
@@ -221,12 +226,21 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
221
226
  | `GET /api/runs/<key>/findings` | `200 RunFindings` |
222
227
  | `GET /api/runs/<key>/executions/<qa-or-dwarf>/transcript` | `200 ExecutionTranscript` |
223
228
  | `GET /api/models` | `200 Models` |
229
+ | `GET /api/health` | `200 {"app": "forge-ui", "version", "build", "pid", "controls": true\|false}`; `controls` is `false` in read-only mode |
230
+ | `GET /api/runs/<key>/controls` | `200` the `forge control show --json` object of a plan run (see Controls); `404` for a solo or unknown run |
231
+ | `POST /api/runs/<key>/controls` | change one control or pin; authenticated, see Controls (write endpoints) |
232
+ | `GET /api/runs/<key>/issues?scope=run\|repo&offline=0\|1&plausible=0\|1&dismissed=0\|1&only=fp,fp` | `200` the `forge issues --json` listing, run fresh on every request (no cache); see Controls (write endpoints) |
233
+ | `POST /api/runs/<key>/issues` | file GitHub issues for chosen findings; authenticated, 300 s timeout |
234
+ | `POST /api/runs/<key>/issues/dismiss` | dismiss or (`undo`) restore findings; authenticated |
235
+ | `POST /api/runs/<key>/delete` | dry-run or delete a run with `forge runs rm`; authenticated, 900 s timeout; later requests for the key answer `404` |
236
+ | `GET /api/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
237
+ | `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
224
238
  | `GET /api/runs/<key>/export?texts=0\|1&paths=0\|1` | `200` the run as one HTML file (see Export), `Content-Disposition: attachment`, `Content-Security-Policy: default-src 'none'; sandbox`; `404` for an unknown run |
225
239
  | `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
226
240
  | `GET /api/runs/<key>/tasks/<id>/fractal/nodes/<node>[/diff]` | `200 FractalNodeDetail`, or with `/diff` a `DiffDetail` of its `candidate.diff`; `<node>` matches `[A-Za-z0-9][A-Za-z0-9_-]{0,100}` |
227
241
  | `GET /` | `forge_ui/static/index.html` |
228
242
  | `GET /<name>` | that file from `forge_ui/static/` (`.html .js .css .svg` only, no subdirs) |
229
- | anything else | `404 {"error": "not found"}`; any method but GET or HEAD: `405` |
243
+ | anything else | `404 {"error": "not found"}` (an unknown path or run); a method a path does not take (PUT, DELETE, OPTIONS, a POST to a read path): `405` with `Allow`, except that a GET to a path that only takes POST (`/api/runs/<key>/delete`, `/api/runs/<key>/issues/dismiss`) answers `404` |
230
244
 
231
245
  The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
232
246
  execution or review; the diff endpoints also for a `<which>` the run kind has not (`existing`
@@ -386,7 +400,7 @@ Every RunSummary field, plus:
386
400
  "attempt": 2,
387
401
  "attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
388
402
  {"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
389
- "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
403
+ "escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
390
404
  "verdict": "PASS",
391
405
  "accepted_by": null,
392
406
  "explored": true,
@@ -421,6 +435,7 @@ Every RunSummary field, plus:
421
435
  - `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
422
436
  else `null`.
423
437
  - `infra` in an attempt: `null` for empty, `-` or missing.
438
+ - `source` in an escalation: the 5th column of `escalations.tsv` (`policy`, `retry` or `pin`, as forge wrote it); `policy` when the row has only 4 columns or the cell is empty.
424
439
  - `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
425
440
  - `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
426
441
  plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
@@ -892,10 +907,173 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
892
907
  style-src <sha256 of the style>; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none';
893
908
  form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
894
909
  JSON escapes `<`, `>` and `&`.
910
+ - The write token (below) is never in an export: the file has no controls.
895
911
  - In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
896
912
  exported run (any other route goes to it) and a banner with the export's date and what it left
897
913
  out; the cloud, the leaderboard and notifications are not there.
898
914
 
915
+ ## Controls (write endpoints)
916
+
917
+ forge-ui changes a run only by calling the forge CLI. The server holds no code that writes a run
918
+ dir, a repo or config; a write route builds an argv, runs it, and answers with what the CLI said.
919
+ `forge_ui/control.py` holds the route table (`ROUTES`: method, path pattern, handler), the body
920
+ validation, the call and the exit-code mapping; `server.py` does the authentication and gives
921
+ each authenticated POST to the table, so a new write route is one entry in it.
922
+
923
+ **The call.** `[<forge root>/forge, ...]`, an argv list with no shell, `stdin` closed, in a process
924
+ group of its own. `<forge root>` is two directories above `forge_ui/` and can be replaced as
925
+ `forge_ui.server.forge_cli`. A call that outlives its timeout (60 s) gets SIGTERM for its whole
926
+ group, SIGKILL 5 s later if anything is left, and is reaped, all before the run's lock is
927
+ released; the answer is `504 {"error": "timeout", "detail": "the operation may have partly
928
+ completed; reload to see the current state"}`.
929
+
930
+ **Runs are named by key.** The path holds the 12-hex run key and nothing else; the server finds
931
+ the run dir with `Catalog.locate(key)`. A body field never names a path, and a field the route does
932
+ not know is a `400`.
933
+
934
+ **Authentication.** Each server process makes a random token (`secrets.token_urlsafe(32)`). `GET /`
935
+ (and `/index.html`) carries it as `<meta name="forge-ui-token" content="...">` as the first element
936
+ of `<head>`; the page script reads it and sends it back. The page's CSP is unchanged (its hash
937
+ covers only the import map). Every POST must pass all of these, checked in this order, or it is
938
+ refused before any route or CLI runs:
939
+
940
+ | Check | Refusal |
941
+ |---|---|
942
+ | `Host` is `127.0.0.1` or `localhost` (any port) | `403 {"error": "forbidden"}` |
943
+ | server is not read-only | `405` (below) |
944
+ | `X-Forge-UI-Token` equals the token (`hmac.compare_digest`) | `403` |
945
+ | `Content-Type` is `application/json` (parameters allowed) | `400` |
946
+ | `Origin`, when sent, is exactly `http://` + the `Host` header | `403` |
947
+ | `Content-Length` present, a number, no `Transfer-Encoding` | `400` |
948
+ | the body is at most 64 KiB | `413 {"error": "payload too large"}` |
949
+ | the body is UTF-8 JSON and an object | `400 {"error": "bad request"}` |
950
+
951
+ No response carries `Access-Control-*` headers and `OPTIONS` is a `405` (`Allow` lists the methods
952
+ the path takes), so a page on another origin can neither preflight nor read an answer. The token is
953
+ not in `/export`, in a log line or in any JSON response.
954
+
955
+ **Read-only mode.** `python3 -m forge_ui --read-only`, or `FORGE_UI_READONLY=1` (also `true`, `yes`,
956
+ `on`). Every POST (after the Host check) is `405 {"error": "method not allowed"}` with
957
+ `Allow: GET, HEAD`, the page has no token, and `/api/health` says `"controls": false`
958
+ (`true` otherwise). Reads, `GET .../controls` included, still work. `forge ui open` passes the mode
959
+ to the server it starts, records it in `server.json` (`read_only`) and never reuses a running server
960
+ of the other mode: it replaces it as it does one running other code. `forge ui status` shows the
961
+ mode (`read_only` in its JSON).
962
+
963
+ **The CLIs' configuration.** The server `forge ui open` starts runs without any `FORGE_*` variable
964
+ of the shell it was started from. The CLIs its writes call still need the forge configuration, so
965
+ `open` hands them these, as that shell has them (an empty value counts as unset; a relative
966
+ `FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory):
967
+ `FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
968
+ `XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
969
+ state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
970
+ call gets them on top of the server's own environment; the server's environment itself is not
971
+ changed. No other variable is passed, and none of these is a secret. `server.json` records them as
972
+ `cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
973
+
974
+ `open` never reuses a server whose recorded `cli_env` differs from the current shell's. It names
975
+ each variable that differs and both values, says to run `forge ui stop` and then open again, exits
976
+ `5`, prints no link and leaves the server running (it may be in the middle of another shell's
977
+ write). A server
978
+ running other code, in the other mode or reading another registry directory is still replaced, and
979
+ the new one gets the current shell's configuration.
980
+
981
+ **Serialization.** One write per run key at a time. A second write to a run whose write is still
982
+ running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
983
+
984
+ **Errors.** For a CLI that exits non-zero the answer is `{"error", "detail", "result"?}`: `detail`
985
+ is the last line of its stderr (at most 300 characters; the rest of stderr is not echoed) and
986
+ `result` the object its stdout holds, when that parses as one.
987
+
988
+ | CLI exit | Status | `error` |
989
+ |---|---|---|
990
+ | 0 | `200` | the CLI's stdout JSON object, as it is |
991
+ | 2 (invalid input) | `400` | `invalid` |
992
+ | 3 (refused: not live, MERGED, a Fractal plan, a pin that fails doctor, the control lock busy) | `409` | `refused` |
993
+ | 4 (a failure the CLI reported) | `500` | `failed` |
994
+ | anything else, a signal, a missing CLI, or a 0 with no JSON object | `502` | `bad gateway` |
995
+ | timeout | `504` | `timeout` |
996
+
997
+ For the controls routes, unknown run keys, solo runs (they have no controls) and runs the
998
+ catalog does not hold are `404 {"error": "not found"}`. The issues, dismiss, delete and config
999
+ routes below accept plan and solo runs alike (`forge issues` and `forge runs rm` take both);
1000
+ for them only unknown keys and runs the catalog does not hold are `404`. Every write that reaches a route logs
1001
+ `forge-ui: control <route> <run key> -> <status>` unless the server runs with `--quiet`.
1002
+
1003
+ ### `GET /api/runs/<key>/controls`
1004
+
1005
+ `forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
1006
+ `run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
1007
+ `retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
1008
+ is needed to read it.
1009
+
1010
+ ### `POST /api/runs/<key>/controls`
1011
+
1012
+ One of these bodies. Every string is non-empty, at most 256 characters, has no control character
1013
+ and does not start with `-`; `400 {"error": "invalid", "detail": ...}` otherwise, and for a wrong
1014
+ shape or type, before the CLI is called. Whether a key exists, a value is in range, a task is
1015
+ known or a spec runs is for the CLI to say (its `400` or `409` comes back as above).
1016
+
1017
+ | Body | CLI call (after `forge`) |
1018
+ |---|---|
1019
+ | `{"action": "set", "key": "qa-threshold", "value": "P2"}` | `control set <dir> qa-threshold P2 --actor ui --json` |
1020
+ | `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
1021
+ | `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
1022
+ | `{"action": "unpin", "task": "graph"}` | `control unpin <dir> graph --actor ui --json` |
1023
+
1024
+ `value` is always a string. `task` is optional on `set`, and only for the keys the CLI allows it
1025
+ for. A success is `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where
1026
+ `controls` is the `GET` object after the change.
1027
+
1028
+ ### `GET /api/runs/<key>/issues`
1029
+
1030
+ `forge issues <dir> --json [flags]`: the findings the run left open. Query (all optional, an
1031
+ unknown or repeated parameter is a `400`): `scope=run|repo` (`repo` adds `--all-runs`),
1032
+ `offline=0|1` (`--offline`), `plausible=0|1` (`--include-plausible`), `dismissed=0|1`
1033
+ (`--include-dismissed`), `only=fp,fp` (`--only`; one to 25 fingerprints, each `^[0-9a-f]{12}$`).
1034
+ `200` with the CLI's object (`findings`, ...). **Never cached**: every request is one CLI call; the
1035
+ page asks for an `offline=1` listing first for speed and then for the full one. It takes no lock (a
1036
+ busy CLI is its own exit 3, `409`). No token is needed. Plan and solo runs. Timeout 120 s.
1037
+
1038
+ ### `POST /api/runs/<key>/issues`
1039
+
1040
+ File issues for findings. Body: `{"scope": "run"|"repo" (default run), "fingerprints": [1 to 25 of
1041
+ ^[0-9a-f]{12}$], "include_plausible": bool, "labels": [at most 5 texts of 1 to 50 characters, no
1042
+ control character, not starting with "-"]}`; any other field is a `400`. Runs `issues <dir> --only
1043
+ <fps> --max-issues <number of fps> --apply --json` plus `--all-runs`, `--include-plausible` and one
1044
+ `--label` per label. `200` with the CLI's object. Under the run's write lock (`409 busy`). Timeout
1045
+ 300 s.
1046
+
1047
+ ### `POST /api/runs/<key>/issues/dismiss`
1048
+
1049
+ Body: `{"fingerprints": [1 to 25], "reason"?: text of at most 500 characters without control characters (newline and tab are allowed), "undo"?: bool}`. Runs
1050
+ `issues <dir> --json --dismiss <fps> [--reason R]`, or `--undismiss <fps>` when `undo` is true
1051
+ (a reason with `undo` is a `400`). Under the run's write lock. Timeout 60 s.
1052
+
1053
+ ### `POST /api/runs/<key>/delete`
1054
+
1055
+ Body: `{"dry_run": bool (false), "archive": bool (true), "worktrees": bool (false), "branches": bool
1056
+ (false)}`. Runs `runs rm <dir> --json` with `--dry-run` or `--yes`, plus `--no-archive` when
1057
+ `archive` is false, `--worktrees`, `--branches`. `200` with the CLI's object. The CLI prints its
1058
+ JSON on failure too, so a non-zero exit answers with that object as `result` (its `steps` show what
1059
+ completed): exit 2 `400`, exit 3 (busy or the run is active) `409`, exit 4 (failed during
1060
+ deletion) `500`. Deletion holds the run's write lock and a server-wide lock on the run's real
1061
+ directory (`409 busy` when either is held). The run key is looked up again under the lock, and
1062
+ once the directory is gone every later request for that key is `404`. Timeout 900 s.
1063
+
1064
+ ### `GET /api/config?run=<key>` and `POST /api/config`
1065
+
1066
+ The repository is the run's own `repo` file (first line, an existing absolute directory; else
1067
+ `409`), never a client field. `GET` runs `config show --repo <repo> --json` and answers with its
1068
+ object. `POST` takes `{"run": <key>, "key": one of repo.verify, repo.setup, repo.verify-subset,
1069
+ jev.enabled, "value": text | "unset": true}` (exactly one of the two; any other field, `repo`
1070
+ included, is a `400`) and runs `config set <key> <value> --repo <repo> --json` or `config unset
1071
+ <key> --repo <repo> --json`. A value is a non-empty text of at most 4096 characters without
1072
+ control characters (newline and tab are allowed), not starting with `-`; `jev.enabled` takes
1073
+ `true` or `false` and cannot be unset; `repo.verify-subset` must contain `{files}`. `200` with the
1074
+ CLI's object (`ok`, `key`, `previous`, `value`). Writes serialize on one server-wide config lock
1075
+ (`409 busy`). Unknown run `404`. Timeout 60 s.
1076
+
899
1077
  ## Status vocabulary for the page and the graph
900
1078
 
901
1079
  Both the page and the graph mark a task by its status with the CSS class
@@ -21,7 +21,7 @@ DEFAULT_PORT = 8765
21
21
  def _parser():
22
22
  parser = argparse.ArgumentParser(
23
23
  prog='python3 -m forge_ui',
24
- description='A small, read-only local UI for forge runs. Listens on 127.0.0.1 only.')
24
+ description='A small local UI for forge runs. Listens on 127.0.0.1 only. Its changes to a run go through the forge CLI.')
25
25
  parser.add_argument('--plan', action='append', default=[], metavar='DIR',
26
26
  help='a plan dir to show (repeatable)')
27
27
  parser.add_argument('--root', action='append', default=[], metavar='DIR',
@@ -35,11 +35,19 @@ def _parser():
35
35
  parser.add_argument('--idle-exit', type=int, default=0, metavar='SECONDS',
36
36
  help='exit after SECONDS with no request (default 0, which never exits)')
37
37
  parser.add_argument('--quiet', action='store_true', help='do not log requests')
38
+ parser.add_argument('--read-only', action='store_true',
39
+ help='answer no write at all (also FORGE_UI_READONLY=1); the page shows no controls')
38
40
  parser.add_argument('--open', action='store_true', help='open the page in the browser once started')
39
41
  parser.add_argument('--version', action='version', version=f'forge-ui {__version__}')
40
42
  return parser
41
43
 
42
44
 
45
+ def read_only_requested(flag, environ=None):
46
+ """--read-only, or FORGE_UI_READONLY set to 1/true/yes/on."""
47
+ value = (os.environ if environ is None else environ).get('FORGE_UI_READONLY', '')
48
+ return bool(flag) or value.strip().lower() in ('1', 'true', 'yes', 'on')
49
+
50
+
43
51
  def _message(level, text):
44
52
  print(f'forge-ui: {level}: {text}', file=sys.stderr)
45
53
 
@@ -100,7 +108,7 @@ def main(argv=None):
100
108
  catalog = reader.Catalog(plans=args.plan, roots=args.root, registry=not args.no_registry,
101
109
  registry_dir=args.registry_dir)
102
110
  try:
103
- server = make_server(catalog, HOST, args.port, quiet=args.quiet)
111
+ server = make_server(catalog, HOST, args.port, quiet=args.quiet, read_only=read_only_requested(args.read_only))
104
112
  except OSError as exc:
105
113
  if exc.errno == errno.EADDRINUSE:
106
114
  _message('error', in_use)