@giamme/forge 0.10.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 +30 -0
- package/SKILL.md +4 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +11 -0
- package/docs/commands.md +8 -3
- package/docs/dataset.md +13 -0
- package/docs/decomposed-runs.md +13 -3
- package/docs/forge-ui.md +8 -3
- package/docs/memory.md +4 -2
- package/docs/reviews.md +73 -2
- package/forge +16 -1
- package/package.json +1 -1
- package/references/dataset.md +86 -4
- package/references/decompose.md +24 -6
- package/references/harnesses.md +43 -1
- package/references/ui.md +4 -1
- package/scripts/forge-config.py +5 -2
- package/scripts/forge-control.py +4 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +344 -16
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +132 -14
- package/scripts/forge-parallel.sh +96 -11
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +72 -12
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge_dataset/__init__.py +7 -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 +23 -2
- package/scripts/forge_dataset/importer.py +20 -8
- 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 +15 -10
- package/ui/README.md +4 -3
- package/ui/docs/api.md +75 -15
- package/ui/forge_ui/control.py +12 -0
- package/ui/forge_ui/reader.py +184 -11
- package/ui/forge_ui/static/app.js +24 -10
- 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/findingsview.js +29 -3
- package/ui/forge_ui/static/graph.js +6 -5
- 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 +52 -10
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/style.css +7 -3
|
@@ -49,15 +49,19 @@ def registry_dir() -> Path:
|
|
|
49
49
|
"""The ``runs`` directory under forge's config home.
|
|
50
50
|
|
|
51
51
|
Resolved the same way ``forge_jev.config_path()`` resolves ``jev.json``:
|
|
52
|
-
``XDG_CONFIG_HOME`` when
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise
|
|
53
|
+
``~/.config``. An empty or relative value counts as unset, as the XDG spec says
|
|
54
|
+
("If an implementation encounters a relative path in any of these variables it
|
|
55
|
+
should consider the path invalid and ignore it") and as forge-ui's run list reads
|
|
56
|
+
it; taken literally it named a directory below the current one, usually the
|
|
57
|
+
user's repo. ``config_path()`` is ``<that>/forge/jev.json``; the registry is the
|
|
58
|
+
sibling ``runs`` directory.
|
|
57
59
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
61
|
+
base = Path(value).expanduser() if value else None
|
|
62
|
+
if base is None or not base.is_absolute():
|
|
63
|
+
base = Path.home() / '.config'
|
|
64
|
+
return base / 'forge' / 'runs'
|
|
61
65
|
|
|
62
66
|
|
|
63
67
|
def repo_key(repo) -> str:
|
|
@@ -118,8 +122,9 @@ def register_run_dir(repo: Path, run_dir: Path) -> None:
|
|
|
118
122
|
it went to.
|
|
119
123
|
"""
|
|
120
124
|
try:
|
|
121
|
-
# A relative
|
|
122
|
-
#
|
|
125
|
+
# A relative HOME (registry_dir already ignores a relative XDG_CONFIG_HOME) would resolve
|
|
126
|
+
# below the current directory -- for a runner, usually the user's repo. Forge does not
|
|
127
|
+
# write into the user's tree.
|
|
123
128
|
if not registry_dir().is_absolute():
|
|
124
129
|
return
|
|
125
130
|
registry = runs_registry(repo)
|
package/ui/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Sources are checked in this order:
|
|
|
96
96
|
2. `--root DIR` itself, its children and grandchildren. Scanning stops when a plan
|
|
97
97
|
directory is found; hidden children and symlinked child directories are skipped.
|
|
98
98
|
3. Forge's registry: `*.txt` files in `$XDG_CONFIG_HOME/forge/runs/`, defaulting
|
|
99
|
-
to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset or
|
|
99
|
+
to `~/.config/forge/runs/` when XDG_CONFIG_HOME is unset, empty or relative. Each file
|
|
100
100
|
supplies absolute paths, one per line; blank lines and `#` comments are ignored.
|
|
101
101
|
`--registry-dir` replaces this location; `--no-registry` disables it.
|
|
102
102
|
|
|
@@ -189,8 +189,9 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
|
|
|
189
189
|
- **Models (`#/models`):** a leaderboard of the models forge used. Implementers: tasks, first-attempt
|
|
190
190
|
pass rate with its 95% Wilson interval, censored attempts, retries, escalations away from the
|
|
191
191
|
model, timeouts, gate and human accepts, median model time and tokens. Reviewers: primary,
|
|
192
|
-
shadow and final reviews, the PASS/FAIL split, findings per review by severity,
|
|
193
|
-
shadow review agreed with the primary
|
|
192
|
+
shadow and final reviews, the PASS/FAIL split, findings per review by severity, how often a
|
|
193
|
+
shadow review agreed with the primary, and how often work it PASSed did not hold (later
|
|
194
|
+
reverted or rewritten, from `forge dataset reviewers`; "not enough data" below 10 outcomes). Filter by project, difficulty, kind and period, or keep
|
|
194
195
|
only exploratory picks; open a difficulty for its own table. Routing is not random, so the
|
|
195
196
|
numbers compare what happened, not what would have: the page says so. A model's name opens the
|
|
196
197
|
run cloud filtered to its runs.
|
package/ui/docs/api.md
CHANGED
|
@@ -50,7 +50,7 @@ written), and the reader must never raise because of one.
|
|
|
50
50
|
| `deferrals.txt` | one line per task the wave planner deferred |
|
|
51
51
|
| `integration.base`, `accepted.integration` | one commit each: where the integration branch started, the revision the run accepted |
|
|
52
52
|
| `attempts/<role>-<uuid>/` | one *execution* (below) of the run itself: `parallel` (a `run`) or `verification` |
|
|
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`, `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>/` |
|
|
54
54
|
| `final-review` | a symlink to the newest *published* review dir; `final-review.verdict` a copy of its verdict |
|
|
55
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 |
|
|
56
56
|
| `tasks/<id>/merged` | exists when the task was merged; its mtime is when |
|
|
@@ -60,7 +60,7 @@ written), and the reader must never raise because of one.
|
|
|
60
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` |
|
|
61
61
|
| `tasks/<id>/qa.last` | the reviewer's final message; its last line matching `FORGE_VERDICT: PASS` or `FORGE_VERDICT: FAIL` is the verdict |
|
|
62
62
|
| `tasks/<id>/prompt.md`, `approach.md`, `retry_findings.md` | free text |
|
|
63
|
-
| `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) |
|
|
64
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) |
|
|
65
65
|
| `tasks/<id>/known_issues.md` | the confirmed findings a threshold tolerated |
|
|
66
66
|
| `tasks/<id>/dwarf.input`, `qa.input` | the prompts forge assembled; `<role>.delivered`, `<role>.resolved` the live dispatch files |
|
|
@@ -233,6 +233,7 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
|
|
|
233
233
|
| `POST /api/runs/<key>/issues` | file GitHub issues for chosen findings; authenticated, 300 s timeout |
|
|
234
234
|
| `POST /api/runs/<key>/issues/dismiss` | dismiss or (`undo`) restore findings; authenticated |
|
|
235
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 |
|
|
236
237
|
| `GET /api/config?run=<key>` | `200` the `forge config show --json` of that run's repo |
|
|
237
238
|
| `POST /api/config` | set or unset one allowed key (`repo.verify`, `repo.setup`, `repo.verify-subset`, `jev.enabled`); authenticated |
|
|
238
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 |
|
|
@@ -286,18 +287,20 @@ nothing when there is none. Every other response keeps
|
|
|
286
287
|
"counts": {"MERGED": 3, "RUNNING": 1, "PENDING": 1},
|
|
287
288
|
"verification_status": "PASS",
|
|
288
289
|
"created": 1760619000.0,
|
|
289
|
-
"task_statuses": [{"id": "reader", "status": "MERGED", "difficulty": "high", "title": "Read plan dirs"},
|
|
290
|
-
{"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}],
|
|
291
292
|
"models": ["opus", "sol:xhigh"],
|
|
292
293
|
"dwarf_models": ["sol:xhigh"],
|
|
293
294
|
"qa_models": ["opus"],
|
|
294
|
-
"final_review": "FAIL"
|
|
295
|
+
"final_review": "FAIL",
|
|
296
|
+
"final_review_outdated": false
|
|
295
297
|
}
|
|
296
298
|
```
|
|
297
299
|
|
|
298
|
-
- `task_statuses`: every task in `tasks.tsv` order, with its `difficulty` (`null` when empty)
|
|
299
|
-
`title` (its first 160 characters, then `…`)
|
|
300
|
-
"
|
|
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`.
|
|
301
304
|
- `created`: when the run began: the earliest birth time (else mtime) of the files a run writes
|
|
302
305
|
once at its start (plan: `run_id`, `repo`, `request.md`, `goal.txt`, `tasks.tsv`; solo: `prompt.md`,
|
|
303
306
|
`repo`, `request.md`, `goal.txt`), else `null`. These are stat'ed only, and never move `updated`.
|
|
@@ -306,6 +309,11 @@ nothing when there is none. Every other response keeps
|
|
|
306
309
|
and `qa` specs as its task has them), nulls left out.
|
|
307
310
|
- `final_review`: the first word of `final-review.verdict`, else `null`; always `null` for a
|
|
308
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`).
|
|
309
317
|
|
|
310
318
|
`kind` is `"plan"` or `"solo"`. `updated` is the newest mtime (epoch seconds, float) among
|
|
311
319
|
the run dir and the files the reader read. Any field it cannot determine is `null`
|
|
@@ -316,7 +324,11 @@ are `null`, `task_count` is 1, and `counts` is `{<status>: 1}` using the solo st
|
|
|
316
324
|
The summary reads only `repo`, `goal.txt`, `status`, `verdict`, `dwarf.spec`, `qa.spec` and,
|
|
317
325
|
when a spec file is missing, `*.resolved`. It also stats `dwarf.log` and `qa.log` without
|
|
318
326
|
reading them, so `updated` moves while a model is writing a log. A plan summary reads
|
|
319
|
-
`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.
|
|
320
332
|
|
|
321
333
|
## RunDetail
|
|
322
334
|
|
|
@@ -370,7 +382,9 @@ Every RunSummary field, plus:
|
|
|
370
382
|
path of its review dir or `null`, `ended` the latest end of its verification and review
|
|
371
383
|
executions (`null` while one is unfinished or there is none).
|
|
372
384
|
- `final_review` is the newest FinalReviewSummary (review and integrate dirs together, by the
|
|
373
|
-
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.
|
|
374
388
|
- `deferrals`: the non-empty lines of `deferrals.txt`, `[]` when it is missing.
|
|
375
389
|
- `findings_available`: whether `scripts/forge-contract.py` could be loaded.
|
|
376
390
|
- `fractal`: `null` without `fractal-selection.json`, else `{"enabled", "run", "available", "reason"}`
|
|
@@ -403,6 +417,9 @@ Every RunSummary field, plus:
|
|
|
403
417
|
"escalations": [{"at": 1760620500.0, "attempt": 2, "from": "luna", "to": "sol", "source": "policy"}],
|
|
404
418
|
"verdict": "PASS",
|
|
405
419
|
"accepted_by": null,
|
|
420
|
+
"accepted": false,
|
|
421
|
+
"accepted_attempt": null,
|
|
422
|
+
"accepted_over": null,
|
|
406
423
|
"explored": true,
|
|
407
424
|
"route": [{"at": 1760619000.0, "task": "reader", "tier": "high", "epsilon": 0.5,
|
|
408
425
|
"candidates": ["astra", "opus"], "default": "opus", "chosen": "astra",
|
|
@@ -437,6 +454,14 @@ Every RunSummary field, plus:
|
|
|
437
454
|
- `infra` in an attempt: `null` for empty, `-` or missing.
|
|
438
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.
|
|
439
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.
|
|
440
465
|
- `dwarf_tail`: the last 4,000 characters of `dwarf.last` (`tasks/<id>/dwarf.last` for a
|
|
441
466
|
plan task, the run dir's `dwarf.last` for a solo run), or `null` when the file is missing.
|
|
442
467
|
Capped like `qa_tail`.
|
|
@@ -468,8 +493,9 @@ it is missing or `<harness default>`). `wave` is 1, `branch` is `null`, and `att
|
|
|
468
493
|
from the run dir's `attempts.tsv`. `status` is the solo status; `attempt` is the highest `n`
|
|
469
494
|
in `attempts.tsv`, or `null`. `escalations`, `route` and `shadow` are empty. `verdict` and
|
|
470
495
|
`qa_tail` come from `qa.last`, and `prompt` comes from `prompt.md`. `deps` and `files` are
|
|
471
|
-
`[]`; `difficulty`, `accepted_by`, `approach` and `retry_findings` are `null`; `
|
|
472
|
-
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`.
|
|
473
499
|
|
|
474
500
|
## Execution
|
|
475
501
|
|
|
@@ -538,6 +564,7 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
|
|
|
538
564
|
```json
|
|
539
565
|
{"name": "final-review-1760622000-4242", "source": "review", "at": 1760622000.0, "published": true,
|
|
540
566
|
"verdict": "FAIL", "state": "done", "spec": "opus", "threshold": "P2", "base": "1a2b...", "tip": "3c4d...",
|
|
567
|
+
"integration": "3c4d...", "outdated": false, "merged_since": [],
|
|
541
568
|
"gate": {...}, "findings": FindingsSummary, "execution": "qa-...",
|
|
542
569
|
"started": 1760622010.0, "ended": 1760622400.0, "duration_s": 390.0}
|
|
543
570
|
```
|
|
@@ -548,7 +575,29 @@ block's 1-based line in the reply. Lists of Findings keep the first 200.
|
|
|
548
575
|
publishes nothing).
|
|
549
576
|
- `state`: `done` with a verdict, `infra` with `qa.infra`, `running` when it is the newest
|
|
550
577
|
review and has `qa.input`, else `orphaned`.
|
|
551
|
-
- `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.
|
|
552
601
|
- `execution` is its newest `attempts/qa-*`; `started`, `ended`, `duration_s` are that
|
|
553
602
|
execution's.
|
|
554
603
|
|
|
@@ -662,13 +711,15 @@ lists, for all tasks and final reviews at once, without `text` or `line`.
|
|
|
662
711
|
"verdict": "FAIL", "findings": [{"id": "qa-…:0", "severity": "P1", "status": "CONFIRMED",
|
|
663
712
|
"location": "ui/reader.py:42", "summary": "…"}]}]}],
|
|
664
713
|
"final_reviews": [{"name": "final-review-…", "execution": "qa-…", "spec": "sol", "verdict": "FAIL",
|
|
714
|
+
"outdated": true, "merged_since": ["reader"],
|
|
665
715
|
"findings": [{"id": "qa-…:0", "severity": "P2", "status": "CONFIRMED", "location": null, "summary": "…"}]}],
|
|
666
716
|
"truncated": [], "warnings": []}
|
|
667
717
|
```
|
|
668
718
|
|
|
669
719
|
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.
|
|
671
|
-
|
|
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
|
|
672
723
|
`final:<n>` for a reply without an execution); the DiffDetail that pins it gives it the same
|
|
673
724
|
`id`, so a mark on the stage opens that Finding in the diff. `available` is `false` without the
|
|
674
725
|
contract module, and the lists are then empty.
|
|
@@ -1061,6 +1112,15 @@ deletion) `500`. Deletion holds the run's write lock and a server-wide lock on t
|
|
|
1061
1112
|
directory (`409 busy` when either is held). The run key is looked up again under the lock, and
|
|
1062
1113
|
once the directory is gone every later request for that key is `404`. Timeout 900 s.
|
|
1063
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
|
+
|
|
1064
1124
|
### `GET /api/config?run=<key>` and `POST /api/config`
|
|
1065
1125
|
|
|
1066
1126
|
The repository is the run's own `repo` file (first line, an existing absolute directory; else
|
package/ui/forge_ui/control.py
CHANGED
|
@@ -538,6 +538,17 @@ def _config_set(ctx):
|
|
|
538
538
|
|
|
539
539
|
# (method, path pattern, handler(ctx) -> (status, body)); group 1 of the pattern is the run key
|
|
540
540
|
# (the config routes name it in the query or body instead). New routes are added here.
|
|
541
|
+
def _reviewer_outcomes(ctx):
|
|
542
|
+
# How the work each reviewer PASSed held up afterwards (`forge dataset reviewers`): advisory,
|
|
543
|
+
# read from the run dataset through the CLI, so forge_ui never opens the store itself.
|
|
544
|
+
ctx.route = 'reviewers'
|
|
545
|
+
try:
|
|
546
|
+
_query_params(ctx.query, ())
|
|
547
|
+
except BadBody as exc:
|
|
548
|
+
return _invalid(str(exc))
|
|
549
|
+
return ctx.call('dataset', 'reviewers', '--json')
|
|
550
|
+
|
|
551
|
+
|
|
541
552
|
ROUTES = [
|
|
542
553
|
('GET', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _show),
|
|
543
554
|
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/controls'), _change),
|
|
@@ -547,6 +558,7 @@ ROUTES = [
|
|
|
547
558
|
('POST', re.compile(r'/api/runs/([0-9a-f]{12})/delete'), _delete),
|
|
548
559
|
('GET', re.compile(r'/api/config()'), _config_get),
|
|
549
560
|
('POST', re.compile(r'/api/config()'), _config_set),
|
|
561
|
+
('GET', re.compile(r'/api/models/reviewers()'), _reviewer_outcomes),
|
|
550
562
|
]
|
|
551
563
|
|
|
552
564
|
|
package/ui/forge_ui/reader.py
CHANGED
|
@@ -42,6 +42,21 @@ def _kind(path):
|
|
|
42
42
|
return None
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _default_registry_dir():
|
|
46
|
+
"""forge's run registry, resolved the way forge writes it (scripts/forge_registry.registry_dir).
|
|
47
|
+
|
|
48
|
+
``XDG_CONFIG_HOME`` (``~`` expanded) when it is an absolute path, otherwise ``~/.config``: an
|
|
49
|
+
empty or relative value counts as unset (XDG spec), as it does when forge registers a run.
|
|
50
|
+
None only when even that is not absolute (a relative HOME): forge never registers a run
|
|
51
|
+
there, so reading it would only pick up whatever the current directory happens to hold.
|
|
52
|
+
"""
|
|
53
|
+
value = os.environ.get('XDG_CONFIG_HOME') or ''
|
|
54
|
+
base = Path(value).expanduser() if value else None
|
|
55
|
+
if base is None or not base.is_absolute():
|
|
56
|
+
base = Path.home() / '.config'
|
|
57
|
+
return base / 'forge' / 'runs' if base.is_absolute() else None
|
|
58
|
+
|
|
59
|
+
|
|
45
60
|
def discover(plans=(), roots=(), registry=True, registry_dir=None):
|
|
46
61
|
found, seen = [], set()
|
|
47
62
|
|
|
@@ -76,10 +91,9 @@ def discover(plans=(), roots=(), registry=True, registry_dir=None):
|
|
|
76
91
|
for path in roots:
|
|
77
92
|
scan(Path(path))
|
|
78
93
|
if registry:
|
|
79
|
-
base = Path(registry_dir) if registry_dir is not None else
|
|
80
|
-
os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'forge' / 'runs'
|
|
94
|
+
base = Path(registry_dir) if registry_dir is not None else _default_registry_dir()
|
|
81
95
|
try:
|
|
82
|
-
entries = sorted(base.glob('*.txt'))
|
|
96
|
+
entries = sorted(base.glob('*.txt')) if base is not None else []
|
|
83
97
|
except OSError:
|
|
84
98
|
entries = []
|
|
85
99
|
for entry in entries:
|
|
@@ -310,10 +324,14 @@ def _tasks(reader):
|
|
|
310
324
|
reader.warn(label, 'invalid task id')
|
|
311
325
|
continue
|
|
312
326
|
prefix = f'tasks/{ident}'
|
|
313
|
-
|
|
327
|
+
# One stat: the run list needs the mtime, and detail reads the same marker again.
|
|
328
|
+
merged = reader.stat(prefix + '/merged')
|
|
329
|
+
status = 'MERGED' if merged is not None else reader.word(prefix + '/status') or 'PENDING'
|
|
314
330
|
tasks.append(dict(id=ident, title='\t'.join(row[6:]), deps=_list(deps),
|
|
315
331
|
difficulty=difficulty, files=_list(files), dwarf=_nullable(dwarf),
|
|
316
|
-
qa=_nullable(qa), status=status
|
|
332
|
+
qa=_nullable(qa), status=status,
|
|
333
|
+
merged_at=float(merged.st_mtime) if merged is not None else None,
|
|
334
|
+
accepted=_nonempty(reader, prefix + '/accepted.by')))
|
|
317
335
|
return tasks
|
|
318
336
|
|
|
319
337
|
|
|
@@ -353,12 +371,15 @@ def _summary(reader, source):
|
|
|
353
371
|
final_review = reader.word('final-review.verdict')
|
|
354
372
|
dwarf_models = sorted({task['dwarf'] for task in tasks if task['dwarf']})
|
|
355
373
|
qa_models = sorted({task['qa'] for task in tasks if task['qa']})
|
|
374
|
+
# Before snapshotting `updated`: these reads are files the summary read, so a newer
|
|
375
|
+
# accepted.integration (or the published verdict / review.meta) moves the activity time.
|
|
376
|
+
final_review_outdated = _published_outdated(reader, tasks)
|
|
356
377
|
result.update(updated=reader.updated, created=_created(reader.path, _PLAN_START_FILES),
|
|
357
378
|
task_count=len(tasks), counts=counts,
|
|
358
379
|
task_statuses=[dict(id=task['id'], status=task['status'], difficulty=task['difficulty'] or None,
|
|
359
|
-
title=_brief(task['title'])) for task in tasks],
|
|
380
|
+
title=_brief(task['title']), accepted=task['accepted']) for task in tasks],
|
|
360
381
|
models=sorted(set(dwarf_models) | set(qa_models)), dwarf_models=dwarf_models, qa_models=qa_models,
|
|
361
|
-
final_review=final_review)
|
|
382
|
+
final_review=final_review, final_review_outdated=final_review_outdated)
|
|
362
383
|
return result, tasks
|
|
363
384
|
|
|
364
385
|
|
|
@@ -389,9 +410,10 @@ def _solo_summary(reader, source):
|
|
|
389
410
|
decompose_level=None, verification_status=None, updated=reader.updated,
|
|
390
411
|
created=_created(reader.path, _SOLO_START_FILES),
|
|
391
412
|
task_count=1, counts={status: 1},
|
|
392
|
-
task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run')
|
|
413
|
+
task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run'),
|
|
414
|
+
accepted=False)],
|
|
393
415
|
models=sorted({dwarf, qa} - {None}), dwarf_models=[dwarf] if dwarf else [],
|
|
394
|
-
qa_models=[qa] if qa else [], final_review=None)
|
|
416
|
+
qa_models=[qa] if qa else [], final_review=None, final_review_outdated=None)
|
|
395
417
|
|
|
396
418
|
|
|
397
419
|
def summarize(path, source='plan') -> dict:
|
|
@@ -502,6 +524,26 @@ def _shadows(reader, ident, task_execs):
|
|
|
502
524
|
return result
|
|
503
525
|
|
|
504
526
|
|
|
527
|
+
def _accepted_fields(reader, name, text):
|
|
528
|
+
"""(attempt, over) from an accepted.by body. A non-integer attempt= is null and warned.
|
|
529
|
+
|
|
530
|
+
status= FAIL or UNKNOWN is what was accepted over; any other or missing value is null
|
|
531
|
+
(a PASS record is an acceptance with nothing to say beyond "accepted").
|
|
532
|
+
"""
|
|
533
|
+
if not text:
|
|
534
|
+
return None, None
|
|
535
|
+
fields = executions.parse_resolved(text)
|
|
536
|
+
attempt = None
|
|
537
|
+
raw = fields.get('attempt')
|
|
538
|
+
if raw:
|
|
539
|
+
try:
|
|
540
|
+
attempt = int(raw)
|
|
541
|
+
except (ValueError, OverflowError):
|
|
542
|
+
reader.warn(name, 'attempt is not an integer')
|
|
543
|
+
status = fields.get('status')
|
|
544
|
+
return attempt, status if status in ('FAIL', 'UNKNOWN') else None
|
|
545
|
+
|
|
546
|
+
|
|
505
547
|
def _task_detail(reader, task, run_id, waves, branches):
|
|
506
548
|
"""Fill in a plan task; returns every execution it read (its own and its shadows')."""
|
|
507
549
|
ident = task['id']
|
|
@@ -520,6 +562,8 @@ def _task_detail(reader, task, run_id, waves, branches):
|
|
|
520
562
|
task['qa_tail'] = reader.cap(qa, prefix + '/qa_tail', 4000, True)
|
|
521
563
|
task['dwarf_tail'] = reader.cap(reader.text(prefix + '/dwarf.last'), prefix + '/dwarf_tail', 4000, True)
|
|
522
564
|
task['accepted_by'] = reader.text(prefix + '/accepted.by')
|
|
565
|
+
task['accepted_attempt'], task['accepted_over'] = _accepted_fields(
|
|
566
|
+
reader, prefix + '/accepted.by', task['accepted_by'])
|
|
523
567
|
for field, filename in [('prompt', 'prompt.md'), ('approach', 'approach.md'), ('retry_findings', 'retry_findings.md')]:
|
|
524
568
|
task[field] = reader.cap(reader.text(prefix + '/' + filename), prefix + '/' + field)
|
|
525
569
|
task['route'] = []
|
|
@@ -606,6 +650,120 @@ def _published(reader):
|
|
|
606
650
|
return real if executions.inside(reader.path, real) else None
|
|
607
651
|
|
|
608
652
|
|
|
653
|
+
def _review_clock(reader, name):
|
|
654
|
+
"""(source, at) of a `final-review-<e>-<p>` or `integrate-<e>-<p>` dir name, else None.
|
|
655
|
+
|
|
656
|
+
`at` is the whole-second epoch in the name. The same clock `executions.review_dirs` uses,
|
|
657
|
+
so a summary does not have to scan the plan to know when the published review started.
|
|
658
|
+
"""
|
|
659
|
+
match = executions._KIND.fullmatch(name)
|
|
660
|
+
if not match:
|
|
661
|
+
return None
|
|
662
|
+
kind, epoch, _pid = match.groups()
|
|
663
|
+
if len(epoch) > 15:
|
|
664
|
+
reader.warn(name, 'invalid epoch')
|
|
665
|
+
return None
|
|
666
|
+
try:
|
|
667
|
+
at = float(epoch)
|
|
668
|
+
if not math.isfinite(at):
|
|
669
|
+
raise ValueError()
|
|
670
|
+
except (ValueError, OverflowError):
|
|
671
|
+
reader.warn(name, 'invalid epoch')
|
|
672
|
+
return None
|
|
673
|
+
return ('review' if kind == 'final-review' else 'integrate'), at
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _published_identity(reader):
|
|
677
|
+
"""(source, at) of the dir `final-review` links to, when the link stays inside the plan."""
|
|
678
|
+
real = _published(reader)
|
|
679
|
+
if real is None:
|
|
680
|
+
return None
|
|
681
|
+
path = Path(real)
|
|
682
|
+
# An integrate review is `<integrate-dir>/final-review`; a final-review dir is named itself.
|
|
683
|
+
if path.name == 'final-review':
|
|
684
|
+
found = _review_clock(reader, path.parent.name)
|
|
685
|
+
return found if found is not None and found[0] == 'integrate' else None
|
|
686
|
+
found = _review_clock(reader, path.name)
|
|
687
|
+
return found if found is not None and found[0] == 'review' else None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _merge_pairs(tasks):
|
|
691
|
+
"""(id, merged_at) of every merged task, oldest merge first, then id."""
|
|
692
|
+
pairs = [(task['id'], task['merged_at']) for task in tasks if task.get('merged_at') is not None]
|
|
693
|
+
pairs.sort(key=lambda item: (item[1], item[0]))
|
|
694
|
+
return pairs
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# What `git rev-parse` writes for a commit: sha1 (40) or sha256 (64), lowercase hex.
|
|
698
|
+
_COMMIT = re.compile(r'(?:[0-9a-f]{40}|[0-9a-f]{64})')
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _meta_integration(reader, name, meta):
|
|
702
|
+
"""The `integration=` commit of a review.meta, or None.
|
|
703
|
+
|
|
704
|
+
Missing or empty is absent (older runs record no integration commit). Any other value
|
|
705
|
+
that is not a commit object name is malformed: null, and a warning, so freshness uses
|
|
706
|
+
the next rule instead of treating the text as the commit the review judged.
|
|
707
|
+
"""
|
|
708
|
+
value = meta.get('integration') or None
|
|
709
|
+
if not value:
|
|
710
|
+
return None
|
|
711
|
+
if _COMMIT.fullmatch(value):
|
|
712
|
+
return value
|
|
713
|
+
reader.warn(name, 'integration is not a commit')
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _review_freshness(accepted, integration, tip, source, at, merges, verdict):
|
|
718
|
+
"""(outdated, merged_since) for one final review.
|
|
719
|
+
|
|
720
|
+
Null when the review has no verdict or there is no `accepted.integration` to compare with.
|
|
721
|
+
Otherwise the first rule that applies: a known `integration` commit, else `tip` when
|
|
722
|
+
`source` is `review`, else (an older integrate review) any task merged after `at`.
|
|
723
|
+
`integration` is a commit object name or None (a malformed value is None). A merge counts
|
|
724
|
+
when its marker mtime is `>= at + 1`. `merged_since` is those ids, oldest first, and is
|
|
725
|
+
empty unless the review is outdated.
|
|
726
|
+
"""
|
|
727
|
+
later = []
|
|
728
|
+
if at is not None:
|
|
729
|
+
later = [item for item in merges if item[1] is not None and item[1] >= at + 1]
|
|
730
|
+
later.sort(key=lambda item: (item[1], item[0]))
|
|
731
|
+
ids = [task_id for task_id, _merged_at in later]
|
|
732
|
+
if not verdict or not accepted:
|
|
733
|
+
return None, []
|
|
734
|
+
if integration:
|
|
735
|
+
moved = integration != accepted
|
|
736
|
+
elif source == 'review':
|
|
737
|
+
moved = tip != accepted
|
|
738
|
+
else:
|
|
739
|
+
moved = bool(ids)
|
|
740
|
+
return (True, ids) if moved else (False, [])
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _stamp_review(summary, accepted, merges):
|
|
744
|
+
summary['outdated'], summary['merged_since'] = _review_freshness(
|
|
745
|
+
accepted, summary['integration'], summary['tip'], summary['source'], summary['at'], merges,
|
|
746
|
+
summary['verdict'])
|
|
747
|
+
return summary
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _published_outdated(reader, tasks):
|
|
751
|
+
"""final_review_outdated for the published review only.
|
|
752
|
+
|
|
753
|
+
Reads `accepted.integration` and, through the `final-review` link, that dir's `verdict`
|
|
754
|
+
and `review.meta`. Does not scan other review dirs or open a review's attempts.
|
|
755
|
+
"""
|
|
756
|
+
found = _published_identity(reader)
|
|
757
|
+
if found is None:
|
|
758
|
+
return None
|
|
759
|
+
source, at = found
|
|
760
|
+
verdict = reader.word('final-review/verdict')
|
|
761
|
+
meta = executions.parse_resolved(reader.text('final-review/review.meta'))
|
|
762
|
+
return _review_freshness(reader.line('accepted.integration'),
|
|
763
|
+
_meta_integration(reader, 'final-review/review.meta', meta),
|
|
764
|
+
meta.get('tip') or None, source, at, _merge_pairs(tasks), verdict)[0]
|
|
765
|
+
|
|
766
|
+
|
|
609
767
|
def _final_review(reader, item, newest, published):
|
|
610
768
|
"""(FinalReviewSummary, its executions, review.meta) of one review dir."""
|
|
611
769
|
name, source, at, rel = item
|
|
@@ -630,6 +788,8 @@ def _final_review(reader, item, newest, published):
|
|
|
630
788
|
verdict=verdict, state=state, spec=meta.get('spec') or None,
|
|
631
789
|
threshold=None if threshold == 'none' else threshold,
|
|
632
790
|
base=meta.get('base') or None, tip=meta.get('tip') or None,
|
|
791
|
+
integration=_meta_integration(reader, rel + '/review.meta', meta),
|
|
792
|
+
outdated=None, merged_since=[],
|
|
633
793
|
gate=_gate(reader, rel + '/qa.gate'), findings=executions.qa_result(reader, rel + '/qa.last')[1],
|
|
634
794
|
execution=run['id'] if run else None, started=run['started'] if run else None,
|
|
635
795
|
ended=run['ended'] if run else None, duration_s=run['duration_s'] if run else None)
|
|
@@ -701,6 +861,7 @@ def _solo_detail(reader, source):
|
|
|
701
861
|
dwarf=_role_spec(reader, 'dwarf'), qa=_role_spec(reader, 'qa'), wave=1, status=status,
|
|
702
862
|
branch=None, attempt=max((item['attempt'] for item in attempts), default=None),
|
|
703
863
|
attempts=attempts, escalations=[], verdict=_verdict(qa), accepted_by=None,
|
|
864
|
+
accepted=False, accepted_attempt=None, accepted_over=None,
|
|
704
865
|
explored=False, route=[], clef={'first_pass': None, 'retry_pass': {}}, shadow=[],
|
|
705
866
|
prompt=reader.cap(reader.text('prompt.md'), 'tasks/solo/prompt'), approach=None,
|
|
706
867
|
retry_findings=None, qa_tail=reader.cap(qa, 'tasks/solo/qa_tail', 4000, True),
|
|
@@ -767,6 +928,10 @@ def detail(path, source='plan') -> dict:
|
|
|
767
928
|
result['timeline'] = _timeline(pool + run_execs, [item for item in run_execs if item['role'] == 'parallel'],
|
|
768
929
|
[item for item in run_execs if item['role'] == 'verification'])
|
|
769
930
|
result['integration'] = _integration(reader, tasks, integrations, reviews, published)
|
|
931
|
+
accepted = result['integration']['accepted'] if result['integration'] is not None else None
|
|
932
|
+
merges = [(item['id'], item['at']) for item in result['integration']['merged']] if result['integration'] else []
|
|
933
|
+
for summary in reviews:
|
|
934
|
+
_stamp_review(summary, accepted, merges)
|
|
770
935
|
result['final_review'] = reviews[0] if reviews else None
|
|
771
936
|
result['final_reviews'] = reviews
|
|
772
937
|
result['deferrals'] = [line.strip() for line in (reader.text('deferrals.txt') or '').splitlines() if line.strip()]
|
|
@@ -943,6 +1108,7 @@ def final_review_detail(path, name, source='plan'):
|
|
|
943
1108
|
if index is None:
|
|
944
1109
|
return None
|
|
945
1110
|
summary, _, meta = _final_review(reader, items[index], index == 0, _published(reader))
|
|
1111
|
+
_stamp_review(summary, reader.line('accepted.integration'), _merge_pairs(_tasks(reader)))
|
|
946
1112
|
rel = items[index][3]
|
|
947
1113
|
qa_last = reader.text(rel + '/qa.last')
|
|
948
1114
|
result = dict(summary)
|
|
@@ -1215,8 +1381,13 @@ def run_findings(path, source='plan'):
|
|
|
1215
1381
|
if kind is None:
|
|
1216
1382
|
return None
|
|
1217
1383
|
keys = ('kind', 'execution', 'attempt', 'spec', 'verdict')
|
|
1384
|
+
if kind == 'solo':
|
|
1385
|
+
task_ids, merges, accepted = ['solo'], [], None
|
|
1386
|
+
else:
|
|
1387
|
+
rows = _tasks(reader)
|
|
1388
|
+
task_ids, merges, accepted = [task['id'] for task in rows], _merge_pairs(rows), reader.line('accepted.integration')
|
|
1218
1389
|
tasks = []
|
|
1219
|
-
for task_id in
|
|
1390
|
+
for task_id in task_ids:
|
|
1220
1391
|
ctx = _diff_task(reader, task_id)
|
|
1221
1392
|
reviews = [dict({key: review[key] for key in keys}, findings=found)
|
|
1222
1393
|
for review in (ctx['reviews'] if ctx else []) for found in [_findings_of(reader, review)] if found]
|
|
@@ -1227,13 +1398,15 @@ def run_findings(path, source='plan'):
|
|
|
1227
1398
|
items, published = executions.review_dirs(reader)[0], _published(reader)
|
|
1228
1399
|
for index, item in enumerate(items[:_MAX_REVIEWS]):
|
|
1229
1400
|
summary = _final_review(reader, item, index == 0, published)[0]
|
|
1401
|
+
_stamp_review(summary, accepted, merges)
|
|
1230
1402
|
# the same review final_review_diff pins, so a Finding's id opens it there
|
|
1231
1403
|
review = _diff_review('final', None, None, summary['spec'], summary['verdict'], item[3] + '/qa.last', [],
|
|
1232
1404
|
summary['execution'])
|
|
1233
1405
|
found = _findings_of(reader, review)
|
|
1234
1406
|
if found:
|
|
1235
1407
|
finals.append(dict(name=item[0], execution=summary['execution'], spec=summary['spec'],
|
|
1236
|
-
verdict=summary['verdict'],
|
|
1408
|
+
verdict=summary['verdict'], outdated=summary['outdated'],
|
|
1409
|
+
merged_since=summary['merged_since'], findings=found))
|
|
1237
1410
|
return dict(run=run_key(reader.path), available=findings.available(), tasks=tasks, final_reviews=finals,
|
|
1238
1411
|
truncated=reader.truncated, warnings=reader.warnings)
|
|
1239
1412
|
|