@giamme/forge 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
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
|
|
|
@@ -48,17 +50,17 @@ written), and the reader must never raise because of one.
|
|
|
48
50
|
| `deferrals.txt` | one line per task the wave planner deferred |
|
|
49
51
|
| `integration.base`, `accepted.integration` | one commit each: where the integration branch started, the revision the run accepted |
|
|
50
52
|
| `attempts/<role>-<uuid>/` | one *execution* (below) of the run itself: `parallel` (a `run`) or `verification` |
|
|
51
|
-
| `final-review-<epoch>-<pid>/` | one whole-run review: `qa.input`, `qa.last`, `verdict` (`PASS FAIL UNKNOWN INVALIDATED ACCEPTED`; absent while it runs or when nothing was judged), `qa.infra`, `qa.gate`, `known_issues.md`, `review.meta` (`key=value`: `base tip source spec threshold`, `threshold=none` when unset), `tasks.summary`, `attempts/qa-<uuid>/` |
|
|
53
|
+
| `final-review-<epoch>-<pid>/` | one whole-run review: `qa.input`, `qa.last`, `verdict` (`PASS FAIL UNKNOWN INVALIDATED ACCEPTED`; absent while it runs or when nothing was judged), `qa.infra`, `qa.gate`, `known_issues.md`, `review.meta` (`key=value`: `base tip source spec threshold integration`, `threshold=none` when unset; `integration` is the integration-branch commit the reviewed result contains and is absent on older runs), `tasks.summary`, `attempts/qa-<uuid>/` |
|
|
52
54
|
| `final-review` | a symlink to the newest *published* review dir; `final-review.verdict` a copy of its verdict |
|
|
53
55
|
| `integrate-<epoch>-<pid>/` | one `integrate`: `setup.log`, `merge.log`, `verification.*`, `attempts/verification-<uuid>/`, and `final-review/` (a review dir as above) when it ran one |
|
|
54
56
|
| `tasks/<id>/merged` | exists when the task was merged; its mtime is when |
|
|
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
|
-
| `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value`
|
|
63
|
+
| `tasks/<id>/accepted.by` | who accepted a task over a reviewer's FAIL, and why (`key=value`: `task`, `status`, `reason`, `by`, `at`, `epoch`, `attempt`, `reviewed_commit`; older runs are free text; returned as text) |
|
|
62
64
|
| `tasks/<id>/qa.gate` | `threshold<TAB>decision<TAB>worst<TAB>n_confirmed<TAB>n_unlabelled<TAB>n_below` (`decision` is `accept` or `keep`, `-` for none) |
|
|
63
65
|
| `tasks/<id>/known_issues.md` | the confirmed findings a threshold tolerated |
|
|
64
66
|
| `tasks/<id>/dwarf.input`, `qa.input` | the prompts forge assembled; `<role>.delivered`, `<role>.resolved` the live dispatch files |
|
|
@@ -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,22 @@ 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/models/reviewers` | `200` the `forge dataset reviewers --json` report (how each reviewer's PASSed work held up); no parameters |
|
|
237
|
+
| `GET /api/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
|
|
238
|
+
| `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
|
|
224
239
|
| `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
240
|
| `GET /api/runs/<key>/tasks/<id>/fractal` | `200 TaskFractal` |
|
|
226
241
|
| `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
242
|
| `GET /` | `forge_ui/static/index.html` |
|
|
228
243
|
| `GET /<name>` | that file from `forge_ui/static/` (`.html .js .css .svg` only, no subdirs) |
|
|
229
|
-
| anything else | `404 {"error": "not found"}
|
|
244
|
+
| 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
245
|
|
|
231
246
|
The lazy endpoints answer `404 {"error": "not found"}` for an unknown run, task,
|
|
232
247
|
execution or review; the diff endpoints also for a `<which>` the run kind has not (`existing`
|
|
@@ -272,18 +287,20 @@ nothing when there is none. Every other response keeps
|
|
|
272
287
|
"counts": {"MERGED": 3, "RUNNING": 1, "PENDING": 1},
|
|
273
288
|
"verification_status": "PASS",
|
|
274
289
|
"created": 1760619000.0,
|
|
275
|
-
"task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs"},
|
|
276
|
-
{"id": "page", "status": "RUNNING", "difficulty": "medium", "title": "Draw the page"}],
|
|
290
|
+
"task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs", "accepted": true},
|
|
291
|
+
{"id": "page", "status": "RUNNING", "difficulty": "medium", "title": "Draw the page", "accepted": false}],
|
|
277
292
|
"models": ["opus", "sol:xhigh"],
|
|
278
293
|
"dwarf_models": ["sol:xhigh"],
|
|
279
294
|
"qa_models": ["opus"],
|
|
280
|
-
"final_review": "FAIL"
|
|
295
|
+
"final_review": "FAIL",
|
|
296
|
+
"final_review_outdated": false
|
|
281
297
|
}
|
|
282
298
|
```
|
|
283
299
|
|
|
284
|
-
- `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty)
|
|
285
|
-
`title` (its first 160 characters, then `…`)
|
|
286
|
-
"
|
|
300
|
+
- `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty),
|
|
301
|
+
`title` (its first 160 characters, then `…`) and `accepted` (`true` when `tasks/<id>/accepted.by`
|
|
302
|
+
is a non-empty file). A solo run: `[{"id": "solo", "status": <solo status>, "difficulty": null,
|
|
303
|
+
"title": <goal or "Solo run">, "accepted": false}]`. `accepted` does not change `status` or `counts`.
|
|
287
304
|
- `created`: when the run began: the earliest birth time (else mtime) of the files a run writes
|
|
288
305
|
once at its start (plan: `run_id`, `repo`, `request.md`, `goal.txt`, `tasks.tsv`; solo: `prompt.md`,
|
|
289
306
|
`repo`, `request.md`, `goal.txt`), else `null`. These are stat'ed only, and never move `updated`.
|
|
@@ -292,6 +309,11 @@ nothing when there is none. Every other response keeps
|
|
|
292
309
|
and `qa` specs as its task has them), nulls left out.
|
|
293
310
|
- `final_review`: the first word of `final-review.verdict`, else `null`; always `null` for a
|
|
294
311
|
solo run. (RunDetail replaces it with an object, below.)
|
|
312
|
+
- `final_review_outdated`: `true`, `false` or `null`, for the published review only (the dir the
|
|
313
|
+
`final-review` link points to, when that link stays inside the plan). The same rule as
|
|
314
|
+
FinalReviewSummary `outdated`, below. `null` when there is no such review, it has no verdict, or
|
|
315
|
+
`accepted.integration` is missing; always `null` for a solo run. RunDetail keeps this field
|
|
316
|
+
(it is not replaced by the newest review's `outdated`).
|
|
295
317
|
|
|
296
318
|
`kind` is `"plan"` or `"solo"`. `updated` is the newest mtime (epoch seconds, float) among
|
|
297
319
|
the run dir and the files the reader read. Any field it cannot determine is `null`
|
|
@@ -302,7 +324,11 @@ are `null`, `task_count` is 1, and `counts` is `{<status>: 1}` using the solo st
|
|
|
302
324
|
The summary reads only `repo`, `goal.txt`, `status`, `verdict`, `dwarf.spec`, `qa.spec` and,
|
|
303
325
|
when a spec file is missing, `*.resolved`. It also stats `dwarf.log` and `qa.log` without
|
|
304
326
|
reading them, so `updated` moves while a model is writing a log. A plan summary reads
|
|
305
|
-
`final-review.verdict` besides the files it always read.
|
|
327
|
+
`final-review.verdict` besides the files it always read. For `final_review_outdated` it also
|
|
328
|
+
reads `accepted.integration` and, through the `final-review` link, that dir's `verdict` and
|
|
329
|
+
`review.meta`, and stats each task's `merged` marker. Those reads count toward `updated`, the
|
|
330
|
+
same way as every other file the summary reads, so a newer `accepted.integration` moves the
|
|
331
|
+
run's activity time. It does not open a review's attempts or scan the other review dirs.
|
|
306
332
|
|
|
307
333
|
## RunDetail
|
|
308
334
|
|
|
@@ -356,7 +382,9 @@ Every RunSummary field, plus:
|
|
|
356
382
|
path of its review dir or `null`, `ended` the latest end of its verification and review
|
|
357
383
|
executions (`null` while one is unfinished or there is none).
|
|
358
384
|
- `final_review` is the newest FinalReviewSummary (review and integrate dirs together, by the
|
|
359
|
-
epoch in the name), or `null`; `final_reviews` lists the newest 20, newest first.
|
|
385
|
+
epoch in the name), or `null`; `final_reviews` lists the newest 20, newest first. That newest
|
|
386
|
+
review's `outdated` is its own. `final_review_outdated` stays the published review's flag, so
|
|
387
|
+
the two differ when a newer review exists than the one `final-review` links to.
|
|
360
388
|
- `deferrals`: the non-empty lines of `deferrals.txt`, `[]` when it is missing.
|
|
361
389
|
- `findings_available`: whether `scripts/forge-contract.py` could be loaded.
|
|
362
390
|
- `fractal`: `null` without `fractal-selection.json`, else `{"enabled", "run", "available", "reason"}`
|
|
@@ -386,9 +414,12 @@ Every RunSummary field, plus:
|
|
|
386
414
|
"attempt": 2,
|
|
387
415
|
"attempts": [{"at": 1760620000.0, "attempt": 1, "status": "FAIL", "infra": null},
|
|
388
416
|
{"at": 1760621000.0, "attempt": 2, "status": "PASS", "infra": null}],
|
|
389
|
-
"escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol"}],
|
|
417
|
+
"escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
|
|
390
418
|
"verdict": "PASS",
|
|
391
419
|
"accepted_by": null,
|
|
420
|
+
"accepted": false,
|
|
421
|
+
"accepted_attempt": null,
|
|
422
|
+
"accepted_over": null,
|
|
392
423
|
"explored": true,
|
|
393
424
|
"route": [{"at": 1760619000.0, "task": "reader", "tier": "high", "epsilon": 0.5,
|
|
394
425
|
"candidates": ["astra", "opus"], "default": "opus", "chosen": "astra",
|
|
@@ -421,7 +452,16 @@ Every RunSummary field, plus:
|
|
|
421
452
|
- `attempt`: integer from `tasks/<id>/attempt`, else the highest attempt in `attempts`,
|
|
422
453
|
else `null`.
|
|
423
454
|
- `infra` in an attempt: `null` for empty, `-` or missing.
|
|
455
|
+
- `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
456
|
- `verdict`: `"PASS"`, `"FAIL"` or `null`, from `qa.last` as described above.
|
|
457
|
+
- `accepted`: `true` when `accepted.by` is a non-empty file, on `task_statuses` and on the task.
|
|
458
|
+
`status` stays what the merged marker or `status` file says (`MERGED` when the marker is there)
|
|
459
|
+
and `counts` do not change.
|
|
460
|
+
- `accepted_attempt`: the integer `attempt=` of `accepted.by`, or `null` when the key is missing
|
|
461
|
+
or not an integer (not an integer is warned). On the task, not on `task_statuses`.
|
|
462
|
+
- `accepted_over`: `"FAIL"` or `"UNKNOWN"` when `status=` in `accepted.by` says so; `null` for any
|
|
463
|
+
other or missing value, including `PASS` (the panel then says plain "accepted"). On the task,
|
|
464
|
+
not on `task_statuses`. A free-text `accepted.by` from an older forge has both null.
|
|
425
465
|
- `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
|
|
426
466
|
plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
|
|
427
467
|
Capped like `qa_tail`.
|
|
@@ -453,8 +493,9 @@ it is missing or `<harness default>`). `wave` is 1, `branch` is `null`, and `att
|
|
|
453
493
|
from the run dir's `attempts.tsv`. `status` is the solo status; `attempt` is the highest `n`
|
|
454
494
|
in `attempts.tsv`, or `null`. `escalations`, `route` and `shadow` are empty. `verdict` and
|
|
455
495
|
`qa_tail` come from `qa.last`, and `prompt` comes from `prompt.md`. `deps` and `files` are
|
|
456
|
-
`[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `
|
|
457
|
-
false; `
|
|
496
|
+
`[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `accepted` is
|
|
497
|
+
false; `accepted_attempt` and `accepted_over` are `null`; `explored` is false; `clef` is
|
|
498
|
+
`{first_pass: null, retry_pass: {}}`. `request` on the detail is `request.md`.
|
|
458
499
|
|
|
459
500
|
## Execution
|
|
460
501
|
|
|
@@ -523,6 +564,7 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
|
|
|
523
564
|
```json
|
|
524
565
|
{"name": "final-review-1760622000-4242", "source": "review", "at": 1760622000.0, "published": true,
|
|
525
566
|
"verdict": "FAIL", "state": "done", "spec": "opus", "threshold": "P2", "base": "1a2b...", "tip": "3c4d...",
|
|
567
|
+
"integration": "3c4d...", "outdated": false, "merged_since": [],
|
|
526
568
|
"gate": {...}, "findings": FindingsSummary, "execution": "qa-...",
|
|
527
569
|
"started": 1760622010.0, "ended": 1760622400.0, "duration_s": 390.0}
|
|
528
570
|
```
|
|
@@ -533,7 +575,29 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
|
|
|
533
575
|
publishes nothing).
|
|
534
576
|
- `state`: `done` with a verdict, `infra` with `qa.infra`, `running` when it is the newest
|
|
535
577
|
review and has `qa.input`, else `orphaned`.
|
|
536
|
-
- `spec`, `threshold`, `base`, `tip` come from `review.meta` (`threshold=none` is
|
|
578
|
+
- `spec`, `threshold`, `base`, `tip`, `integration` come from `review.meta` (`threshold=none` is
|
|
579
|
+
`null`). `integration` is the integration-branch commit the reviewed result contains, or `null`
|
|
580
|
+
when missing, empty, or not a commit. A commit is the object name `git rev-parse` writes: 40
|
|
581
|
+
or 64 lowercase hex digits. Any other `integration=` value is warned on that
|
|
582
|
+
`review.meta` (`integration is not a commit`; the summary's read of the published dir
|
|
583
|
+
warns `final-review/review.meta`) and is not a known commit, so freshness uses the next rule.
|
|
584
|
+
`source` on this object is the dir kind, `review` or `integrate`, not `review.meta`'s
|
|
585
|
+
`source=` (a worktree path).
|
|
586
|
+
- `outdated`: whether the plan's integration branch has moved since this review. `null` when the
|
|
587
|
+
review has no verdict, or when `accepted.integration` is missing. Otherwise the first rule that
|
|
588
|
+
applies:
|
|
589
|
+
1. `integration` is a commit: `true` when it differs from `accepted.integration`.
|
|
590
|
+
2. Else, for `source` `review`: `true` when `tip` differs from `accepted.integration`.
|
|
591
|
+
3. Else (an integrate review from an older forge, which has no `integration=` commit): `true`
|
|
592
|
+
when any task merged after the review started.
|
|
593
|
+
- `merged_since`: the ids of tasks merged after the review started, oldest merge first. `[]` when
|
|
594
|
+
the review is not outdated. It may also be `[]` for an outdated review whose only later merge
|
|
595
|
+
fell inside the review's starting second.
|
|
596
|
+
- A task merged after the review when the mtime of its `merged` marker is `>= at + 1`, where `at`
|
|
597
|
+
is the whole-second epoch in the review dir's name. A merge inside that starting second is
|
|
598
|
+
ambiguous and is not counted. The execution's `started` is not used.
|
|
599
|
+
- `outdated` means tasks merged into the plan's integration branch since the review. Changes on
|
|
600
|
+
the user's own branch after an integrate are not tracked.
|
|
537
601
|
- `execution` is its newest `attempts/qa-*`; `started`, `ended`, `duration_s` are that
|
|
538
602
|
execution's.
|
|
539
603
|
|
|
@@ -647,13 +711,15 @@ lists, for all tasks and final reviews at once, without `text` or `line`.
|
|
|
647
711
|
"verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
|
|
648
712
|
"location": "ui/reader.py:42", "summary": "…"}]}]}],
|
|
649
713
|
"final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
|
|
714
|
+
"outdated": true, "merged_since": ["reader"],
|
|
650
715
|
"findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
|
|
651
716
|
"truncated": [], "warnings": []}
|
|
652
717
|
```
|
|
653
718
|
|
|
654
719
|
A task's `reviews` are every primary and shadow review of every attempt that listed a Finding
|
|
655
|
-
(a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did.
|
|
656
|
-
|
|
720
|
+
(a solo run has one task, `solo`); `final_reviews` the newest 20 final reviews that did. Each
|
|
721
|
+
of those carries `outdated` and `merged_since`, the same values as that review's
|
|
722
|
+
FinalReviewSummary. A Finding's `id` is `<execution>:<n>`, `n` its 0-based place in the reply (`primary:<n>` or
|
|
657
723
|
`final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
|
|
658
724
|
`id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
|
|
659
725
|
contract module, and the lists are then empty.
|
|
@@ -892,10 +958,182 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
|
|
|
892
958
|
style-src <sha256 of the style>; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none';
|
|
893
959
|
form-action 'none'; object-src 'none'`. Inside a script, `</script` and `<!--` are escaped; the
|
|
894
960
|
JSON escapes `<`, `>` and `&`.
|
|
961
|
+
- The write token (below) is never in an export: the file has no controls.
|
|
895
962
|
- In the page, `app.js` reads the snapshot instead of the network, never polls, shows only the
|
|
896
963
|
exported run (any other route goes to it) and a banner with the export's date and what it left
|
|
897
964
|
out; the cloud, the leaderboard and notifications are not there.
|
|
898
965
|
|
|
966
|
+
## Controls (write endpoints)
|
|
967
|
+
|
|
968
|
+
forge-ui changes a run only by calling the forge CLI. The server holds no code that writes a run
|
|
969
|
+
dir, a repo or config; a write route builds an argv, runs it, and answers with what the CLI said.
|
|
970
|
+
`forge_ui/control.py` holds the route table (`ROUTES`: method, path pattern, handler), the body
|
|
971
|
+
validation, the call and the exit-code mapping; `server.py` does the authentication and gives
|
|
972
|
+
each authenticated POST to the table, so a new write route is one entry in it.
|
|
973
|
+
|
|
974
|
+
**The call.** `[<forge root>/forge, ...]`, an argv list with no shell, `stdin` closed, in a process
|
|
975
|
+
group of its own. `<forge root>` is two directories above `forge_ui/` and can be replaced as
|
|
976
|
+
`forge_ui.server.forge_cli`. A call that outlives its timeout (60 s) gets SIGTERM for its whole
|
|
977
|
+
group, SIGKILL 5 s later if anything is left, and is reaped, all before the run's lock is
|
|
978
|
+
released; the answer is `504 {"error": "timeout", "detail": "the operation may have partly
|
|
979
|
+
completed; reload to see the current state"}`.
|
|
980
|
+
|
|
981
|
+
**Runs are named by key.** The path holds the 12-hex run key and nothing else; the server finds
|
|
982
|
+
the run dir with `Catalog.locate(key)`. A body field never names a path, and a field the route does
|
|
983
|
+
not know is a `400`.
|
|
984
|
+
|
|
985
|
+
**Authentication.** Each server process makes a random token (`secrets.token_urlsafe(32)`). `GET /`
|
|
986
|
+
(and `/index.html`) carries it as `<meta name="forge-ui-token" content="...">` as the first element
|
|
987
|
+
of `<head>`; the page script reads it and sends it back. The page's CSP is unchanged (its hash
|
|
988
|
+
covers only the import map). Every POST must pass all of these, checked in this order, or it is
|
|
989
|
+
refused before any route or CLI runs:
|
|
990
|
+
|
|
991
|
+
| Check | Refusal |
|
|
992
|
+
|---|---|
|
|
993
|
+
| `Host` is `127.0.0.1` or `localhost` (any port) | `403 {"error": "forbidden"}` |
|
|
994
|
+
| server is not read-only | `405` (below) |
|
|
995
|
+
| `X-Forge-UI-Token` equals the token (`hmac.compare_digest`) | `403` |
|
|
996
|
+
| `Content-Type` is `application/json` (parameters allowed) | `400` |
|
|
997
|
+
| `Origin`, when sent, is exactly `http://` + the `Host` header | `403` |
|
|
998
|
+
| `Content-Length` present, a number, no `Transfer-Encoding` | `400` |
|
|
999
|
+
| the body is at most 64 KiB | `413 {"error": "payload too large"}` |
|
|
1000
|
+
| the body is UTF-8 JSON and an object | `400 {"error": "bad request"}` |
|
|
1001
|
+
|
|
1002
|
+
No response carries `Access-Control-*` headers and `OPTIONS` is a `405` (`Allow` lists the methods
|
|
1003
|
+
the path takes), so a page on another origin can neither preflight nor read an answer. The token is
|
|
1004
|
+
not in `/export`, in a log line or in any JSON response.
|
|
1005
|
+
|
|
1006
|
+
**Read-only mode.** `python3 -m forge_ui --read-only`, or `FORGE_UI_READONLY=1` (also `true`, `yes`,
|
|
1007
|
+
`on`). Every POST (after the Host check) is `405 {"error": "method not allowed"}` with
|
|
1008
|
+
`Allow: GET, HEAD`, the page has no token, and `/api/health` says `"controls": false`
|
|
1009
|
+
(`true` otherwise). Reads, `GET .../controls` included, still work. `forge ui open` passes the mode
|
|
1010
|
+
to the server it starts, records it in `server.json` (`read_only`) and never reuses a running server
|
|
1011
|
+
of the other mode: it replaces it as it does one running other code. `forge ui status` shows the
|
|
1012
|
+
mode (`read_only` in its JSON).
|
|
1013
|
+
|
|
1014
|
+
**The CLIs' configuration.** The server `forge ui open` starts runs without any `FORGE_*` variable
|
|
1015
|
+
of the shell it was started from. The CLIs its writes call still need the forge configuration, so
|
|
1016
|
+
`open` hands them these, as that shell has them (an empty value counts as unset; a relative
|
|
1017
|
+
`FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory):
|
|
1018
|
+
`FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
|
|
1019
|
+
`XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
|
|
1020
|
+
state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
|
|
1021
|
+
call gets them on top of the server's own environment; the server's environment itself is not
|
|
1022
|
+
changed. No other variable is passed, and none of these is a secret. `server.json` records them as
|
|
1023
|
+
`cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
|
|
1024
|
+
|
|
1025
|
+
`open` never reuses a server whose recorded `cli_env` differs from the current shell's. It names
|
|
1026
|
+
each variable that differs and both values, says to run `forge ui stop` and then open again, exits
|
|
1027
|
+
`5`, prints no link and leaves the server running (it may be in the middle of another shell's
|
|
1028
|
+
write). A server
|
|
1029
|
+
running other code, in the other mode or reading another registry directory is still replaced, and
|
|
1030
|
+
the new one gets the current shell's configuration.
|
|
1031
|
+
|
|
1032
|
+
**Serialization.** One write per run key at a time. A second write to a run whose write is still
|
|
1033
|
+
running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
|
|
1034
|
+
|
|
1035
|
+
**Errors.** For a CLI that exits non-zero the answer is `{"error", "detail", "result"?}`: `detail`
|
|
1036
|
+
is the last line of its stderr (at most 300 characters; the rest of stderr is not echoed) and
|
|
1037
|
+
`result` the object its stdout holds, when that parses as one.
|
|
1038
|
+
|
|
1039
|
+
| CLI exit | Status | `error` |
|
|
1040
|
+
|---|---|---|
|
|
1041
|
+
| 0 | `200` | the CLI's stdout JSON object, as it is |
|
|
1042
|
+
| 2 (invalid input) | `400` | `invalid` |
|
|
1043
|
+
| 3 (refused: not live, MERGED, a Fractal plan, a pin that fails doctor, the control lock busy) | `409` | `refused` |
|
|
1044
|
+
| 4 (a failure the CLI reported) | `500` | `failed` |
|
|
1045
|
+
| anything else, a signal, a missing CLI, or a 0 with no JSON object | `502` | `bad gateway` |
|
|
1046
|
+
| timeout | `504` | `timeout` |
|
|
1047
|
+
|
|
1048
|
+
For the controls routes, unknown run keys, solo runs (they have no controls) and runs the
|
|
1049
|
+
catalog does not hold are `404 {"error": "not found"}`. The issues, dismiss, delete and config
|
|
1050
|
+
routes below accept plan and solo runs alike (`forge issues` and `forge runs rm` take both);
|
|
1051
|
+
for them only unknown keys and runs the catalog does not hold are `404`. Every write that reaches a route logs
|
|
1052
|
+
`forge-ui: control <route> <run key> -> <status>` unless the server runs with `--quiet`.
|
|
1053
|
+
|
|
1054
|
+
### `GET /api/runs/<key>/controls`
|
|
1055
|
+
|
|
1056
|
+
`forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
|
|
1057
|
+
`run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
|
|
1058
|
+
`retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
|
|
1059
|
+
is needed to read it.
|
|
1060
|
+
|
|
1061
|
+
### `POST /api/runs/<key>/controls`
|
|
1062
|
+
|
|
1063
|
+
One of these bodies. Every string is non-empty, at most 256 characters, has no control character
|
|
1064
|
+
and does not start with `-`; `400 {"error": "invalid", "detail": ...}` otherwise, and for a wrong
|
|
1065
|
+
shape or type, before the CLI is called. Whether a key exists, a value is in range, a task is
|
|
1066
|
+
known or a spec runs is for the CLI to say (its `400` or `409` comes back as above).
|
|
1067
|
+
|
|
1068
|
+
| Body | CLI call (after `forge`) |
|
|
1069
|
+
|---|---|
|
|
1070
|
+
| `{"action": "set", "key": "qa-threshold", "value": "P2"}` | `control set <dir> qa-threshold P2 --actor ui --json` |
|
|
1071
|
+
| `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
|
|
1072
|
+
| `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
|
|
1073
|
+
| `{"action": "unpin", "task": "graph"}` | `control unpin <dir> graph --actor ui --json` |
|
|
1074
|
+
|
|
1075
|
+
`value` is always a string. `task` is optional on `set`, and only for the keys the CLI allows it
|
|
1076
|
+
for. A success is `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where
|
|
1077
|
+
`controls` is the `GET` object after the change.
|
|
1078
|
+
|
|
1079
|
+
### `GET /api/runs/<key>/issues`
|
|
1080
|
+
|
|
1081
|
+
`forge issues <dir> --json [flags]`: the findings the run left open. Query (all optional, an
|
|
1082
|
+
unknown or repeated parameter is a `400`): `scope=run|repo` (`repo` adds `--all-runs`),
|
|
1083
|
+
`offline=0|1` (`--offline`), `plausible=0|1` (`--include-plausible`), `dismissed=0|1`
|
|
1084
|
+
(`--include-dismissed`), `only=fp,fp` (`--only`; one to 25 fingerprints, each `^[0-9a-f]{12}$`).
|
|
1085
|
+
`200` with the CLI's object (`findings`, ...). **Never cached**: every request is one CLI call; the
|
|
1086
|
+
page asks for an `offline=1` listing first for speed and then for the full one. It takes no lock (a
|
|
1087
|
+
busy CLI is its own exit 3, `409`). No token is needed. Plan and solo runs. Timeout 120 s.
|
|
1088
|
+
|
|
1089
|
+
### `POST /api/runs/<key>/issues`
|
|
1090
|
+
|
|
1091
|
+
File issues for findings. Body: `{"scope": "run"|"repo" (default run), "fingerprints": [1 to 25 of
|
|
1092
|
+
^[0-9a-f]{12}$], "include_plausible": bool, "labels": [at most 5 texts of 1 to 50 characters, no
|
|
1093
|
+
control character, not starting with "-"]}`; any other field is a `400`. Runs `issues <dir> --only
|
|
1094
|
+
<fps> --max-issues <number of fps> --apply --json` plus `--all-runs`, `--include-plausible` and one
|
|
1095
|
+
`--label` per label. `200` with the CLI's object. Under the run's write lock (`409 busy`). Timeout
|
|
1096
|
+
300 s.
|
|
1097
|
+
|
|
1098
|
+
### `POST /api/runs/<key>/issues/dismiss`
|
|
1099
|
+
|
|
1100
|
+
Body: `{"fingerprints": [1 to 25], "reason"?: text of at most 500 characters without control characters (newline and tab are allowed), "undo"?: bool}`. Runs
|
|
1101
|
+
`issues <dir> --json --dismiss <fps> [--reason R]`, or `--undismiss <fps>` when `undo` is true
|
|
1102
|
+
(a reason with `undo` is a `400`). Under the run's write lock. Timeout 60 s.
|
|
1103
|
+
|
|
1104
|
+
### `POST /api/runs/<key>/delete`
|
|
1105
|
+
|
|
1106
|
+
Body: `{"dry_run": bool (false), "archive": bool (true), "worktrees": bool (false), "branches": bool
|
|
1107
|
+
(false)}`. Runs `runs rm <dir> --json` with `--dry-run` or `--yes`, plus `--no-archive` when
|
|
1108
|
+
`archive` is false, `--worktrees`, `--branches`. `200` with the CLI's object. The CLI prints its
|
|
1109
|
+
JSON on failure too, so a non-zero exit answers with that object as `result` (its `steps` show what
|
|
1110
|
+
completed): exit 2 `400`, exit 3 (busy or the run is active) `409`, exit 4 (failed during
|
|
1111
|
+
deletion) `500`. Deletion holds the run's write lock and a server-wide lock on the run's real
|
|
1112
|
+
directory (`409 busy` when either is held). The run key is looked up again under the lock, and
|
|
1113
|
+
once the directory is gone every later request for that key is `404`. Timeout 900 s.
|
|
1114
|
+
|
|
1115
|
+
### `GET /api/models/reviewers`
|
|
1116
|
+
|
|
1117
|
+
Runs `dataset reviewers --json` and answers with its object: `horizon_days`, `min_landed`,
|
|
1118
|
+
`survives_at`, `reviewers` (per reviewer alias: `landed`, `reverted`, `rewritten`,
|
|
1119
|
+
`did_not_hold`, `enough`, and `rate` and `ci` only when `enough`, else `null` with a `note`),
|
|
1120
|
+
`unattributed`, `skipped` and `other_horizons`. Any query parameter is a `400`. Not cached: every
|
|
1121
|
+
request runs the CLI, and the page asks once per change of `/api/models`. Advisory: it feeds the
|
|
1122
|
+
Reviewers table's Did not hold column and nothing else. Timeout 60 s.
|
|
1123
|
+
|
|
1124
|
+
### `GET /api/config?run=<key>` and `POST /api/config`
|
|
1125
|
+
|
|
1126
|
+
The repository is the run's own `repo` file (first line, an existing absolute directory; else
|
|
1127
|
+
`409`), never a client field. `GET` runs `config show --repo <repo> --json` and answers with its
|
|
1128
|
+
object. `POST` takes `{"run": <key>, "key": one of repo.verify, repo.setup, repo.verify-subset,
|
|
1129
|
+
jev.enabled, "value": text | "unset": true}` (exactly one of the two; any other field, `repo`
|
|
1130
|
+
included, is a `400`) and runs `config set <key> <value> --repo <repo> --json` or `config unset
|
|
1131
|
+
<key> --repo <repo> --json`. A value is a non-empty text of at most 4096 characters without
|
|
1132
|
+
control characters (newline and tab are allowed), not starting with `-`; `jev.enabled` takes
|
|
1133
|
+
`true` or `false` and cannot be unset; `repo.verify-subset` must contain `{files}`. `200` with the
|
|
1134
|
+
CLI's object (`ok`, `key`, `previous`, `value`). Writes serialize on one server-wide config lock
|
|
1135
|
+
(`409 busy`). Unknown run `404`. Timeout 60 s.
|
|
1136
|
+
|
|
899
1137
|
## Status vocabulary for the page and the graph
|
|
900
1138
|
|
|
901
1139
|
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)
|