@giamme/forge 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/SKILL.md +19 -1
- package/VERSION +1 -1
- package/docs/artifacts.md +17 -2
- package/docs/commands.md +50 -0
- package/docs/dataset.md +9 -0
- package/docs/decomposed-runs.md +42 -0
- package/docs/forge-ui.md +42 -6
- package/docs/reviews.md +14 -0
- package/forge +15 -0
- package/package.json +1 -1
- package/references/dataset.md +18 -1
- package/references/decompose.md +19 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +22 -0
- package/scripts/forge_dataset/importer.py +419 -17
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +37 -4
- package/ui/docs/api.md +184 -6
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +26 -1
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +80 -8
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/panel.js +24 -4
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +177 -2
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
|
339
|
-
|
|
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.
|
|
23
|
-
forge
|
|
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"}
|
|
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
|
package/ui/forge_ui/__main__.py
CHANGED
|
@@ -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
|
|
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)
|