@giamme/forge 0.8.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 +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- 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 +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
package/ui/docs/api.md
CHANGED
|
@@ -12,14 +12,17 @@ forge_ui/findings.py QA findings and gate lines, parsed by scripts/forge-cont
|
|
|
12
12
|
forge_ui/diffs.py unified diffs: parsing, the ```diff fence of a review prompt, pinning findings, cached reads
|
|
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
|
-
forge_ui/
|
|
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; authenticates writes
|
|
17
|
+
forge_ui/control.py the write routes: each one is a forge CLI call (see Controls)
|
|
16
18
|
forge_ui/static/ the page: index.html, app.js, style.css, graph.js and the scripts they load
|
|
17
19
|
```
|
|
18
20
|
|
|
19
21
|
Python 3.9+ standard library only. No third-party packages, no build step. The server makes
|
|
20
22
|
no network requests; the page loads three.js 0.160.0 from cdn.jsdelivr.net only when the 3D
|
|
21
|
-
view is used, and falls back to the 2D graph when it can't.
|
|
22
|
-
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.
|
|
23
26
|
|
|
24
27
|
## What a forge plan dir looks like
|
|
25
28
|
|
|
@@ -54,7 +57,7 @@ written), and the reader must never raise because of one.
|
|
|
54
57
|
| `tasks/<id>/status` | one word: `RUNNING PASS FAIL ERROR INFRA TIMEOUT UNKNOWN INVALIDATED BLOCKED INTERRUPTED NOCHANGES CONFLICT` |
|
|
55
58
|
| `tasks/<id>/attempt` | the current attempt number |
|
|
56
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 |
|
|
57
|
-
| `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` |
|
|
58
61
|
| `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
|
|
59
62
|
| `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
|
|
60
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) |
|
|
@@ -85,7 +88,8 @@ one of `task dwarf qa solo verification parallel` and `<uuid>` 32 lowercase hex
|
|
|
85
88
|
| `dataset.json` | may be absent: `parent` (the execution that started this one, e.g. `task-<uuid>`), `trigger`, `attempt_start` and `resume` (task executions) |
|
|
86
89
|
| `<role>.resolved`, `.prompt`, `.last`, `.timedout` | copies made when it finished: the model resolution (`key=value`), the prompt, the final message, the timeout marker |
|
|
87
90
|
|
|
88
|
-
|
|
91
|
+
Only the transcript endpoint opens an execution's `.log` (a `dwarf`'s or `qa`'s, see
|
|
92
|
+
ExecutionTranscript); no read opens a `.cmd`.
|
|
89
93
|
|
|
90
94
|
**Task status** is `MERGED` if `tasks/<id>/merged` exists, otherwise the first word of
|
|
91
95
|
`tasks/<id>/status`, otherwise `PENDING`.
|
|
@@ -158,6 +162,8 @@ def task_diff(path, task_id, which='current', source='plan') -> dict | None # a
|
|
|
158
162
|
def execution_diff(path, name, source='plan') -> dict | None # a DiffDetail
|
|
159
163
|
def final_review_diff(path, name, source='plan') -> dict | None # a DiffDetail
|
|
160
164
|
def files_detail(path, source='plan') -> dict | None # Files
|
|
165
|
+
def run_findings(path, source='plan') -> dict | None # RunFindings
|
|
166
|
+
def execution_transcript(path, name, source='plan') -> dict | None # an ExecutionTranscript
|
|
161
167
|
def fractal_detail(path, task_id, source='plan') -> dict | None # a TaskFractal
|
|
162
168
|
def fractal_node(path, task_id, node, source='plan') -> dict | None # a FractalNodeDetail
|
|
163
169
|
def fractal_node_diff(path, task_id, node, source='plan') -> dict | None # a DiffDetail
|
|
@@ -170,11 +176,15 @@ class Catalog:
|
|
|
170
176
|
def execution(self, key, name) -> dict | None
|
|
171
177
|
def final_review(self, key, name) -> dict | None
|
|
172
178
|
def diff(self, key, task_id, which='current'), execution_diff(self, key, name),
|
|
173
|
-
final_review_diff(self, key, name), files(self, key),
|
|
179
|
+
final_review_diff(self, key, name), files(self, key), findings(self, key), transcript(self, key, name),
|
|
180
|
+
fractal(self, key, task_id),
|
|
174
181
|
fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
|
|
175
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
|
|
176
184
|
```
|
|
177
185
|
|
|
186
|
+
`Catalog.locate` is how a write finds its run: a client names a run by key and never by path.
|
|
187
|
+
|
|
178
188
|
`Catalog` re-discovers and re-reads on every call: runs change while they are watched. Files
|
|
179
189
|
that stop changing once written (`metrics.json`, `dataset.json`, `*.resolved` and `qa.last`
|
|
180
190
|
copies, findings of a reply) are parsed once and kept by `(dev, inode, size, mtime_ns)`; a
|
|
@@ -213,17 +223,29 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
|
|
|
213
223
|
| `GET /api/runs/<key>/executions/<qa-or-dwarf>/diff` | `200 DiffDetail` of the diff that review read (a dwarf: its attempt's primary review) |
|
|
214
224
|
| `GET /api/runs/<key>/final-reviews/<name>/diff` | `200 DiffDetail` |
|
|
215
225
|
| `GET /api/runs/<key>/files` | `200 Files` |
|
|
226
|
+
| `GET /api/runs/<key>/findings` | `200 RunFindings` |
|
|
227
|
+
| `GET /api/runs/<key>/executions/<qa-or-dwarf>/transcript` | `200 ExecutionTranscript` |
|
|
216
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 |
|
|
217
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 |
|
|
218
239
|
| `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
|
|
219
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}` |
|
|
220
241
|
| `GET /` | `forge_ui/static/index.html` |
|
|
221
242
|
| `GET /<name>` | that file from `forge_ui/static/` (`.html .js .css .svg` only, no subdirs) |
|
|
222
|
-
| 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` |
|
|
223
244
|
|
|
224
245
|
The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
|
|
225
246
|
execution or review; the diff endpoints also for a `<which>` the run kind has not (`existing`
|
|
226
|
-
of a plan task, `prev` of a solo run) and for an execution that is
|
|
247
|
+
of a plan task, `prev` of a solo run), the diff and transcript endpoints for an execution that is
|
|
248
|
+
not `qa` or `dwarf`; the
|
|
227
249
|
Fractal node endpoints for an unknown node or a task whose Fractal state is not `available`. A
|
|
228
250
|
diff that is not there yet is a `200` with `available: false`. An execution is looked for only where executions are written:
|
|
229
251
|
`attempts/`, `tasks/<id>/attempts/`, `shadow/<id>/<a>-<k>/attempts/`,
|
|
@@ -378,7 +400,7 @@ Every RunSummary field, plus:
|
|
|
378
400
|
"attempt": 2,
|
|
379
401
|
"attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
|
|
380
402
|
{"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
|
|
381
|
-
"escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
|
|
403
|
+
"escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
|
|
382
404
|
"verdict": "PASS",
|
|
383
405
|
"accepted_by": null,
|
|
384
406
|
"explored": true,
|
|
@@ -413,6 +435,7 @@ Every RunSummary field, plus:
|
|
|
413
435
|
- `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
|
|
414
436
|
else `null`.
|
|
415
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.
|
|
416
439
|
- `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
|
|
417
440
|
- `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
|
|
418
441
|
plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
|
|
@@ -577,7 +600,7 @@ of Findings, in place of the summary), `truncated` and `warnings`.
|
|
|
577
600
|
"additions": 5, "deletions": 1, "truncated": false,
|
|
578
601
|
"hunks": [{"old_start": 38, "old_lines": 7, "new_start": 38, "new_lines": 10,
|
|
579
602
|
"section": "def _tasks(reader):", "lines": [" ctx", "-old", "+new"]}]}],
|
|
580
|
-
"findings": [{"review": {"kind": "primary", "execution": "qa-…", "attempt": 2, "spec": "opus"},
|
|
603
|
+
"findings": [{"id": "qa-…:0", "review": {"kind": "primary", "execution": "qa-…", "attempt": 2, "spec": "opus"},
|
|
581
604
|
"severity": "P1", "status": "CONFIRMED", "location": "ui/reader.py:42",
|
|
582
605
|
"summary": "…", "text": "…",
|
|
583
606
|
"pin": {"file": 0, "path": "ui/reader.py", "line": 42, "side": "new", "hunk": 0, "index": 5},
|
|
@@ -597,7 +620,7 @@ of Findings, in place of the summary), `truncated` and `warnings`.
|
|
|
597
620
|
| `truncated` | the read stopped at 8 MiB (at a line end), the fence never closed, more than 300 files, or a file is `truncated` |
|
|
598
621
|
| `files[]` | per file of the diff: `old` (`null` when added), `new` (`null` when deleted), `status` `modified added deleted renamed copied`, `binary` (`Binary files … differ` or a `GIT binary patch`, whose body is skipped), `additions`, `deletions` (of the whole file, even when cut), `truncated`, `hunks` |
|
|
599
622
|
| `hunks[]` | `old_start`, `old_lines`, `new_start`, `new_lines` (a count left out is 1), `section` (after the second `@@`, or `null`), `lines`: each raw line with its marker (` `, `-`, `+`, `\` for ``) |
|
|
600
|
-
| `findings[]` | the Findings (as above, without `line`) of the reviews attached, each with its `review` (`kind` `primary` `shadow` `final`, `execution`, `attempt`, `spec`), `pin` and `off_diff` |
|
|
623
|
+
| `findings[]` | the Findings (as above, without `line`) of the reviews attached, each with its `id` (see RunFindings), its `review` (`kind` `primary` `shadow` `final`, `execution`, `attempt`, `spec`), `pin` and `off_diff` |
|
|
601
624
|
| `pin` | `file` (index in `files`), `path`, `line`, `side` (`new`; `old` for a deleted file or a renamed file's old path), `hunk` and `index` (into that hunk's `lines`), or `null` |
|
|
602
625
|
| `off_diff` | `null` when pinned to a line, else `no location`, `file not in this diff`, `ambiguous path` or `line outside the changed hunks` (then `pin` has `file`, `line`, `side` and `hunk`/`index` `null`) |
|
|
603
626
|
|
|
@@ -628,6 +651,77 @@ Reads stay inside the run dir after symlinks are resolved, open regular files on
|
|
|
628
651
|
never opened) and read at most 8 MiB; parses are kept by `(dev, inode, size, mtime_ns)` as
|
|
629
652
|
other parses are.
|
|
630
653
|
|
|
654
|
+
## RunFindings
|
|
655
|
+
|
|
656
|
+
Every review's Findings in one read, for the stage's marks: what a TaskDetail or DiffDetail
|
|
657
|
+
lists, for all tasks and final reviews at once, without `text` or `line`.
|
|
658
|
+
|
|
659
|
+
```json
|
|
660
|
+
{"run": "3f9a0c1d2e4b", "available": true,
|
|
661
|
+
"tasks": [{"id": "reader", "reviews": [{"kind": "primary", "execution": "qa-…", "attempt": 1, "spec": "opus",
|
|
662
|
+
"verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
|
|
663
|
+
"location": "ui/reader.py:42", "summary": "…"}]}]}],
|
|
664
|
+
"final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
|
|
665
|
+
"findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
|
|
666
|
+
"truncated": [], "warnings": []}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
A task's `reviews` are every primary and shadow review of every attempt that listed a Finding
|
|
670
|
+
(a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did. A
|
|
671
|
+
Finding's `id` is `<execution>:<n>`, `n` its 0-based place in the reply (`primary:<n>` or
|
|
672
|
+
`final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
|
|
673
|
+
`id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
|
|
674
|
+
contract module, and the lists are then empty.
|
|
675
|
+
|
|
676
|
+
## ExecutionTranscript (`forge_ui/transcripts.py`)
|
|
677
|
+
|
|
678
|
+
What the agent of one `dwarf` or `qa` execution said and did, read from its `<role>.log`: the
|
|
679
|
+
copy in its execution dir once it has finished, the owner dir's log while it runs (up to its
|
|
680
|
+
last whole line, so the page can poll it). The format is told from the log itself:
|
|
681
|
+
|
|
682
|
+
| Log | Written by | Read as |
|
|
683
|
+
|---|---|---|
|
|
684
|
+
| one JSON event per line: `thread.started`, `item.*`, `turn.*` | codex `--json` | `format` `codex` |
|
|
685
|
+
| one message per line in Claude's shape: `system`, `assistant`, `user`, `result` | grok `--output-format streaming-messages-json`; claude and openclaude `--output-format stream-json` | `claude` |
|
|
686
|
+
| one event per line: `text`, `reasoning`, `tool_use`, `step_finish`, `error` | opencode `--format json` | `opencode` |
|
|
687
|
+
| one `result` object with a `session_id` | claude `--output-format json` | the session file, see below |
|
|
688
|
+
| other JSON lines | e.g. agy `--output-format stream-json` (its `{"event": "result"}` is read as the result) | `json`: each line shown as it is |
|
|
689
|
+
| anything else | antigravity's printed output; openclaude and opencode runs from before forge streamed them | `text`: one event |
|
|
690
|
+
|
|
691
|
+
A claude log that holds only its result names the session the conversation is in. That one
|
|
692
|
+
file is read, `<config dir>/projects/<one dir>/<session id>.jsonl`, the config dir being
|
|
693
|
+
`$CLAUDE_CONFIG_DIR` then `~/.claude`, and only when the id is a UUID; it is the one file
|
|
694
|
+
outside the run dir forge-ui reads. Its subagent (`isSidechain`) and meta lines are left out.
|
|
695
|
+
Without it, the result alone is shown with a warning.
|
|
696
|
+
|
|
697
|
+
```json
|
|
698
|
+
{"run": "3f9a0c1d2e4b", "execution": "dwarf-…", "role": "dwarf", "state": "done", "attempt": 1, "spec": "luna",
|
|
699
|
+
"harness": "codex", "repo": "/work/worktrees/reader", "owner": {"kind": "task", "id": "reader"},
|
|
700
|
+
"format": "codex", "source": "log", "session": "0199…", "model": null, "cost": null,
|
|
701
|
+
"tokens": {"input": 12000, "cached": 9000, "output": 800},
|
|
702
|
+
"events": [{"kind": "reasoning", "text": "…"},
|
|
703
|
+
{"kind": "command", "command": "rg -n status ui/reader.py", "output": "…", "exit": 0},
|
|
704
|
+
{"kind": "edit", "files": ["/repo/ui/reader.py"], "patch": "-old\n+new"},
|
|
705
|
+
{"kind": "message", "text": "…"}],
|
|
706
|
+
"truncated": false, "warnings": []}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
`attempt`, `spec` and `owner` are the Execution's; `harness` and `repo` (the worktree the agent ran
|
|
710
|
+
in, so the page can make its absolute paths relative) come from `<role>.resolved`. `source` is
|
|
711
|
+
`log`, `session` (claude's session file) or `none` (no log). An event has `kind`
|
|
712
|
+
and the keys that kind uses: `prompt` (`text`: the task as a session file holds it), `message`,
|
|
713
|
+
`reasoning`, `command` (`command`, `output`, `exit`), `edit` (`files`, `patch`: `-`/`+` lines,
|
|
714
|
+
or `input` when there are none), `tool` (`tool`, `input`, `output`), `result` (`text`, left out
|
|
715
|
+
with `same: true` when the last message said it; `tokens`), `error` (`text`), `text` (`text`,
|
|
716
|
+
`stream: "stderr"` for lines between JSON events) and `gap` (`count` events left out). Any event
|
|
717
|
+
may have `t` (epoch seconds), `id` (the harness's call id), `error` (`true` for a failed call or a
|
|
718
|
+
non-zero exit, `false` for a call answered without one) and `running: true` (a call not finished yet). `tokens` are the run's totals
|
|
719
|
+
(`input` counts cached input too). A message, a reasoning or a result keeps 20,000 characters, a
|
|
720
|
+
command, an input or an output its first two thirds and last third of 8,000 around `… n
|
|
721
|
+
characters …` (`<key>_cut` holds n). A log over 32 MB is read from its end; past 5,000 events or
|
|
722
|
+
4,000,000 characters the middle is left out (a `gap` event, `truncated: true`). Parses are kept by
|
|
723
|
+
the log's `(path, size, mtime_ns)` and the session file's.
|
|
724
|
+
|
|
631
725
|
## Files
|
|
632
726
|
|
|
633
727
|
`GET /api/runs/<key>/files`: who planned, changed, drifted into or conflicted on each file.
|
|
@@ -785,21 +879,23 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
|
|
|
785
879
|
`GET /api/runs/<key>/export` answers one self-contained HTML file named
|
|
786
880
|
`forge-run-<run_id>.html` (the run id with anything but `[A-Za-z0-9._-]` made `-`).
|
|
787
881
|
|
|
788
|
-
- It holds `style.css`
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
`<script type="
|
|
792
|
-
|
|
793
|
-
|
|
882
|
+
- It holds `style.css` (with the labels' face, `D-DINCondensed-Bold.woff2`, inlined as a `data:`
|
|
883
|
+
URL) and the classic scripts the run view uses (`theme.js icons.js datatable.js shell.js
|
|
884
|
+
runmodel.js cloudmodel.js graph.js gantt.js panel.js diffview.js convo.js files.js map3d.js search.js
|
|
885
|
+
app.js`), inline; the import map; a `<script type="application/json" id="forge-snapshot">` with
|
|
886
|
+
`{meta, api}`; and a `<script type="text/plain" id="forge-stage">` holding `iso.js`,
|
|
887
|
+
`cloud3d.js`, `findings3d.js`, `stagectl.js` and `scene3d.js` bundled into one module (each
|
|
888
|
+
module in its own scope, relative imports bound to the earlier ones' exports, `createStage`
|
|
889
|
+
exported), which `map3d.js` imports from a `blob:` URL.
|
|
794
890
|
- `api` maps each path the page requests to that endpoint's response as the server would answer
|
|
795
891
|
it (server.api_get): `/api/runs` (this run only), the RunDetail, every TaskDetail, every
|
|
796
|
-
ExecutionDetail the RunDetail names, every FinalReviewDetail, Files, and the
|
|
797
|
-
nodes. With `texts=1` also every diff the page can open (each task's diff and its `sources`,
|
|
798
|
-
each final review's, each Fractal node's).
|
|
892
|
+
ExecutionDetail the RunDetail names, every FinalReviewDetail, Files, RunFindings, and the
|
|
893
|
+
Fractal trees and nodes. With `texts=1` also every diff the page can open (each task's diff and its `sources`,
|
|
894
|
+
each final review's, each Fractal node's) and every `dwarf` and `qa` ExecutionTranscript (at most 2 MB each).
|
|
799
895
|
- `texts=0` (the default) nulls these keys wherever they hold a string, list or object: `prompt
|
|
800
896
|
last request log_tail dwarf_tail qa_tail qa_last qa_input tasks_summary known_issues texts text
|
|
801
|
-
planning_last approach retry_findings dwarf_input error`, and stores no diff
|
|
802
|
-
`summary`.
|
|
897
|
+
planning_last approach retry_findings dwarf_input error`, and stores no diff and no transcript. A
|
|
898
|
+
finding keeps its `summary`.
|
|
803
899
|
- `paths=0` (the default) makes every string that is a whole absolute path (but `/api/…` and
|
|
804
900
|
`url`s) its last component, and replaces the run dir, the repo and the home dir inside other
|
|
805
901
|
strings with their last component (`~` for home).
|
|
@@ -808,13 +904,176 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
|
|
|
808
904
|
`generated`, `texts`, `paths`, `omitted`.
|
|
809
905
|
- The page's `<meta http-equiv="Content-Security-Policy">`: `default-src 'none'; script-src
|
|
810
906
|
<sha256 of the import map and each inline script> blob: https://cdn.jsdelivr.net/npm/three@0.160.0/;
|
|
811
|
-
style-src <sha256 of the style>; img-src data: blob:; connect-src 'none'; base-uri 'none';
|
|
907
|
+
style-src <sha256 of the style>; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none';
|
|
812
908
|
form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
|
|
813
909
|
JSON escapes `<`, `>` and `&`.
|
|
910
|
+
- The write token (below) is never in an export: the file has no controls.
|
|
814
911
|
- In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
|
|
815
912
|
exported run (any other route goes to it) and a banner with the export's date and what it left
|
|
816
913
|
out; the cloud, the leaderboard and notifications are not there.
|
|
817
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
|
+
|
|
818
1077
|
## Status vocabulary for the page and the graph
|
|
819
1078
|
|
|
820
1079
|
Both the page and the graph mark a task by its status with the CSS class
|
|
@@ -860,6 +1119,7 @@ Plain scripts unless noted; each defines one global and none parses API text as
|
|
|
860
1119
|
| `gantt.js` | `ForgeGantt` | `render(container, run, {now, onSelect, sel, replay, critical, ghosts})`: attempts on wall-clock time in lanes; `strip(container, run, {now, replay})`: busy attempts and model calls against `max_parallel` |
|
|
861
1120
|
| `panel.js` | `ForgePanel` | `render(host, ctx)`: the inspector for a run, task, execution, final review, verification, integration or Fractal node; fetches the lazy endpoints once per node; `ctx.diffHash(spec)` links into the diff viewer, `ctx.onFractal(task, TaskFractal)` hands a task's tree to the stage |
|
|
862
1121
|
| `diffview.js` | `ForgeDiff` | `render(host, {run, spec, fetchJSON, rerender, href, closeHref})`: a DiffDetail with its findings under their lines; `spec` is `{kind: task, task, source, file}`, `{kind: final, name, file}` or `{kind: fractal, task, node, file}` |
|
|
1122
|
+
| `convo.js` | `ForgeConvo` | `render(host, {run, spec: {task, exec}, brief, live, fetchJSON, rerender, diffHash, closeHref})`: an ExecutionTranscript as a timeline, with search, the errors and, while the execution runs, a read of its log every 2.5 s |
|
|
863
1123
|
| `files.js` | `ForgeFiles` | `matrix(data, run, opts)` (pure: Files as rows of dirs and files against task columns by wave) and `render(host, data, {run, sel, onSelectTask, diffHref, prefs, savePrefs})`: the run's Files mode. Also a CommonJS module |
|
|
864
1124
|
| `cloudmodel.js` | `ForgeCloudModel` | pure: projects, per-run facts, the cloud's filters, its five layouts (anchors from every run, dragged runs kept), label picking, layout files. Also a CommonJS module |
|
|
865
1125
|
| `cloudui.js` | `ForgeCloudUI` | `create(root, ctx)`: the cloud's filter panel, layout and shape switches, position tools, legend and run card; a list of the same runs without 3D |
|
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)
|