@giamme/forge 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +46 -12
  5. package/docs/commands.md +48 -9
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-issues.py +1 -1
  18. package/scripts/forge-lib-plans.sh +9 -3
  19. package/scripts/forge-lib-review.sh +22 -3
  20. package/scripts/forge-lib-task.sh +51 -4
  21. package/scripts/forge-lib-verify.sh +137 -6
  22. package/scripts/forge-parallel-options.sh +59 -0
  23. package/scripts/forge-parallel.sh +212 -28
  24. package/scripts/forge-ripwire.py +1 -1
  25. package/scripts/forge-route.py +157 -5
  26. package/scripts/forge-schedule.py +687 -50
  27. package/scripts/forge-ui.py +8 -4
  28. package/scripts/forge-verify.py +878 -0
  29. package/scripts/forge_dataset/forecast.py +29 -4
  30. package/scripts/forge_dataset/harvest.py +10 -4
  31. package/scripts/forge_verify_reports.py +225 -0
  32. package/ui/README.md +19 -4
  33. package/ui/docs/api.md +139 -25
  34. package/ui/forge_ui/control.py +26 -7
  35. package/ui/forge_ui/export.py +20 -7
  36. package/ui/forge_ui/reader.py +307 -11
  37. package/ui/forge_ui/server.py +16 -0
  38. package/ui/forge_ui/static/app.js +2 -1
  39. package/ui/forge_ui/static/controls.js +217 -33
  40. package/ui/forge_ui/static/findingsview.js +37 -12
  41. package/ui/forge_ui/static/gantt.js +14 -9
  42. package/ui/forge_ui/static/index.html +1 -0
  43. package/ui/forge_ui/static/panel.js +2 -1
  44. package/ui/forge_ui/static/runmodel.js +92 -11
  45. package/ui/forge_ui/static/scene3d.js +9 -5
  46. package/ui/forge_ui/static/settings.js +48 -12
  47. package/ui/forge_ui/static/style.css +60 -0
  48. package/ui/forge_ui/static/verifyview.js +324 -0
package/ui/docs/api.md CHANGED
@@ -43,7 +43,9 @@ written), and the reader must never raise because of one.
43
43
  | `shadow_qa` | a comma list of specs |
44
44
  | `timeout` | seconds |
45
45
  | `verification.status` | `PASS`, `FAIL` or another word; `verification.command` the command; `verification.log` its output |
46
- | `verification.runs.tsv` | `n<TAB>kind<TAB>exit<TAB>tree_changed<TAB>duration_s<TAB>logfile` per run of the command; `kind` is `initial`, `rerun` or `jev_retry`, `exit` may be empty, `tree_changed` is `0` or `1` |
46
+ | `verification.runs.tsv` | `n<TAB>kind<TAB>exit<TAB>tree_changed<TAB>duration_s<TAB>logfile` per run of the command; `kind` is `initial`, `rerun` or `jev_retry`, `exit` may be empty, `tree_changed` is `0` or `1`. A lanes verification adds `<TAB>lane<TAB>started` (an epoch) |
47
+ | `verification.retry.log` | the output of a deterministic or Jev rerun of a single command |
48
+ | `verification.lanes.json` | a lanes verification's live and final state, written by `scripts/forge-verify.py` (read up to 4 MB); `verification.lane.<name>.log` and `verification.lane.<name>.run-<k>.log` each lane's output |
47
49
  | `verification.commit` | one line, the commit verified |
48
50
  | `max_parallel`, `escalate_after`, `verify_retries` | one integer each |
49
51
  | `qa_threshold` | `P0`..`P3`, the least severe finding that still blocks; `final_qa` the final review's QA spec |
@@ -167,6 +169,7 @@ def execution_transcript(path, name, source='plan') -> dict | None # an Executio
167
169
  def fractal_detail(path, task_id, source='plan') -> dict | None # a TaskFractal
168
170
  def fractal_node(path, task_id, node, source='plan') -> dict | None # a FractalNodeDetail
169
171
  def fractal_node_diff(path, task_id, node, source='plan') -> dict | None # a DiffDetail
172
+ def verification_log(path, lane=None, k=None, first=None, retry=False, source='plan') -> dict | None # a VerificationLog
170
173
 
171
174
  class Catalog:
172
175
  def __init__(self, plans=(), roots=(), registry=True, registry_dir=None): ...
@@ -178,7 +181,8 @@ class Catalog:
178
181
  def diff(self, key, task_id, which='current'), execution_diff(self, key, name),
179
182
  final_review_diff(self, key, name), files(self, key), findings(self, key), transcript(self, key, name),
180
183
  fractal(self, key, task_id),
181
- fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node) -> dict | None
184
+ fractal_node(self, key, task_id, node), fractal_node_diff(self, key, task_id, node),
185
+ verification_log(self, key, lane=None, k=None, first=None) -> dict | None
182
186
  def models(self) -> dict # Models, over every run
183
187
  def locate(self, key) -> tuple[str, str] | None # (run dir, 'plan' | 'solo'); None for an unknown key
184
188
  ```
@@ -225,6 +229,10 @@ its last 20,000 behind a leading `…`. Their `truncated` lists the field names
225
229
  | `GET /api/runs/<key>/files` | `200 Files` |
226
230
  | `GET /api/runs/<key>/findings` | `200 RunFindings` |
227
231
  | `GET /api/runs/<key>/executions/<qa-or-dwarf>/transcript` | `200 ExecutionTranscript` |
232
+ | `GET /api/runs/<key>/verification/log[/lines/<n>]` | `200 VerificationLog` of `verification.log`: its tail, or with `/lines/<n>` a range |
233
+ | `GET /api/runs/<key>/verification/retry/log[/lines/<n>]` | `200 VerificationLog` of `verification.retry.log`, a single command's rerun |
234
+ | `GET /api/runs/<key>/verification/lanes/<lane>/log[/lines/<n>]` | `200 VerificationLog` of `verification.lane.<lane>.log` |
235
+ | `GET /api/runs/<key>/verification/lanes/<lane>/runs/<k>/log[/lines/<n>]` | `200 VerificationLog` of `verification.lane.<lane>.run-<k>.log` |
228
236
  | `GET /api/models` | `200 Models` |
229
237
  | `GET /api/health` | `200 {"app": "forge-ui", "version", "build", "pid", "controls": true\|false}`; `controls` is `false` in read-only mode |
230
238
  | `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 |
@@ -251,11 +259,14 @@ Fractal node endpoints for an unknown node or a task whose Fractal state is not
251
259
  diff that is not there yet is a `200` with `available: false`. An execution is looked for only where executions are written:
252
260
  `attempts/`, `tasks/<id>/attempts/`, `shadow/<id>/<a>-<k>/attempts/`,
253
261
  `final-review-*/attempts/` and `integrate-*/final-review/attempts/`, in that order; a dir
254
- that resolves outside the run dir (a symlinked `attempts/`, say) is ignored.
262
+ that resolves outside the run dir (a symlinked `attempts/`, say) is ignored. The verification
263
+ log endpoints answer `404` for a solo run, a `<lane>` not matching `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`
264
+ (so no `..`), a `<k>` or `<n>` that is not a positive decimal without a leading zero, and a log
265
+ that is missing, a symlink or not a regular file.
255
266
 
256
267
  JSON responses: `Content-Type: application/json; charset=utf-8`,
257
268
  `Cache-Control: no-store`. The page loads `style.css`, `runmodel.js`, `graph.js`, `gantt.js`,
258
- `panel.js`, `map3d.js` and `app.js` by those relative names, and `scene3d.js` and `iso.js`
269
+ `verifyview.js`, `panel.js`, `map3d.js` and `app.js` by those relative names, and `scene3d.js` and `iso.js`
259
270
  (ES modules) only when the 3D view is used.
260
271
 
261
272
  Every response carries `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`
@@ -342,8 +353,14 @@ Every RunSummary field, plus:
342
353
  "final_qa": "opus"},
343
354
  "verification": {"status": "PASS", "command": "python3 -m unittest", "log_tail": "...",
344
355
  "runs": [{"n": 1, "kind": "initial", "exit": 0, "tree_changed": false,
345
- "duration_s": 12.3, "log": "verification.run-1.log"}],
346
- "commit": "9c1e4b7a..."},
356
+ "duration_s": 12.3, "log": "verification.lane.unit.run-1.log",
357
+ "lane": "unit", "started": 1760622000.5}],
358
+ "commit": "9c1e4b7a...",
359
+ "state": "done", "mode": "lanes", "live": null, "mutated": false,
360
+ "lanes": [VerificationLane, ...],
361
+ "failing": [{"lane": "e2e", "name": "checkout.Pay.test_refund", "suite": "checkout",
362
+ "file": "e2e.xml", "message": "expected 200, got 500", "excerpt": "...",
363
+ "known": false, "log_line": 12}]},
347
364
  "waves": [["reader", "page", "graph"], ["server"], ["readme"]],
348
365
  "tasks": [Task, ...],
349
366
  "timeline": {"started": 1760620000.25, "ended": null, "runs": [Execution], "verifications": [Execution]},
@@ -369,7 +386,53 @@ Every RunSummary field, plus:
369
386
  - `verification` is `null` when none of `verification.status`, `.command`, `.log` exists. A
370
387
  solo run's `verification` is always `null`. `runs` comes from `verification.runs.tsv`
371
388
  (malformed rows are warned and skipped; an empty `exit` is `null`), `commit` is the first
372
- line of `verification.commit` or `null`.
389
+ line of `verification.commit` or `null`. A run's lanes runs carry `lane` and `started`; a
390
+ single command's have `null` for both. A row of neither 6 nor 8 columns is warned and skipped.
391
+ - `verification.state` is `running` when the run's newest `verification` execution (the newest
392
+ of `timeline.verifications`) is running, `done` when it has ended, and `null` when the run has
393
+ none. While it runs, `status` is `RUNNING`, whatever a `verification.status` left by an earlier
394
+ verification says.
395
+ - `verification.mode` is `lanes` when `verification.lanes.json` exists and was written at or
396
+ after the newest verification execution started (or there is none), else `single`. A lanes file
397
+ an earlier verification left is not read.
398
+ - `verification.live` is `null` unless `state` is `running`; then `{started, step, last_line,
399
+ last_output_at, idle_s, log}`. `started` is the execution's start. In `single` mode the live log is
400
+ whichever of `verification.log` and `verification.retry.log` was modified last: `step` is
401
+ `{name, started: null}` for the last `::forge step name=<x> state=start` in its last 64 KB
402
+ that no later `state=end` closed (a line holds no time), `last_line` its last non-empty line
403
+ without ANSI codes (at most 300 characters), and `last_output_at` its mtime. In `lanes` mode
404
+ they are the running lane's that printed last, with its recorded `step.started`. `idle_s` is
405
+ now minus `last_output_at` (minus `started` before any output). `log` names the live log in
406
+ `single` mode (`verification.log` or `verification.retry.log`; `null` before either exists) and is
407
+ `null` in `lanes` mode.
408
+ - `verification.lanes` is `null` in `single` mode and when the lanes file is unreadable, not a
409
+ JSON object, has no `lanes` list or is over 4 MB (each warned; the request never fails). In
410
+ `lanes` mode it is every lane:
411
+
412
+ ```
413
+ VerificationLane = {"name": "e2e", "command": "npm run test:e2e", "group": "browser", "first": false,
414
+ "retries": 2, "options": {"retries": 2, "reports": "reports"},
415
+ "state": "fail", "exit": 1, "started": 1760622000.5, "ended": 1760622070.0,
416
+ "duration_s": 69.5, "idle_s": null,
417
+ "runs": [{"k": 1, "kind": "initial", "started": 1760622000.5, "ended": 1760622030.0,
418
+ "exit": 1, "log": "verification.lane.e2e.run-1.log"}],
419
+ "step": {"name": "browser", "started": 1760622010.0} | null,
420
+ "last_line": "2 failed, 30 passed", "last_output_at": 1760622069.8,
421
+ "log": "verification.lane.e2e.log",
422
+ "tests": {"pass": 30, "fail": 2, "skip": 1, "error": 0, "failing": [...],
423
+ "reports": ["e2e.xml"], "notes": [], "truncated": false} | null}
424
+ ```
425
+
426
+ `state` is one of `waiting running pass fail flaky known skipped interrupted`; `duration_s`
427
+ runs from `started` to `ended`, or to now while the lane runs; `idle_s` is now minus
428
+ `last_output_at` for a running lane, else `null`. `tests` is `null` when the lane wrote no
429
+ report; its `failing` holds at most 200 entries (`truncated` says more failed), a `message` at
430
+ most 500 characters and an `excerpt` at most 2,000; `log_line` is the 1-based line of the
431
+ lane's log that first names the test, or `null`.
432
+ - `verification.failing` is every lane's `tests.failing`, in lane order, each with its `lane`:
433
+ at most 200 entries, `[]` in `single` mode.
434
+ - `verification.mutated` is `true` when the checkout changed under the verification (the lanes
435
+ file's `mutated`, or in `single` mode a run with `tree_changed`): reruns after it were skipped.
373
436
  - `timeline.started` is the earliest `started` of every execution the detail shows (tasks
374
437
  and their shadows, the listed final reviews, the run's own); `ended` is the latest `ended`,
375
438
  or `null` while any of them is `running` or when none has ended. `runs` and
@@ -773,6 +836,30 @@ characters …` (`<key>_cut` holds n). A log over 32 MB is read from its end; pa
773
836
  4,000,000 characters the middle is left out (a `gap` event, `truncated: true`). Parses are kept by
774
837
  the log's `(path, size, mtime_ns)` and the session file's.
775
838
 
839
+ ## VerificationLog
840
+
841
+ The run's own verification logs, read by path (an integration's lane logs are not served yet):
842
+
843
+ ```
844
+ {"name": "verification.lane.e2e.log", "size": 220000, "mtime": 1760622070.0,
845
+ "tail": "line 14044\n...", "tail_first_line": 14044, "truncated": true}
846
+ ```
847
+
848
+ `tail` is the file's last 64 KB, from the first line that starts inside them (when no line
849
+ does, from the first whole character), decoded as UTF-8 with invalid bytes replaced.
850
+ `tail_first_line` is the 1-based number of the tail's first line, found by counting the newlines
851
+ before it, or `null` when that would read past 64 MB. `truncated` says the tail is not the whole
852
+ file. With `/lines/<n>`:
853
+
854
+ ```
855
+ {"name": "verification.log", "size": 11000, "first_line": 700, "lines": ["line 00700", ...],
856
+ "truncated": false}
857
+ ```
858
+
859
+ up to 400 lines from line `n` (1-based), each without its line ending and cut to 4,000
860
+ characters (ending `…`). Lines past the end give `lines: []`. `truncated` is `true` when more
861
+ lines follow, a line was cut, or reaching them took more than 64 MB of reading.
862
+
776
863
  ## Files
777
864
 
778
865
  `GET /api/runs/<key>/files`: who planned, changed, drifted into or conflicted on each file.
@@ -942,13 +1029,15 @@ difficulty paths deps model` each), `decomposition_history` (its newest 20, each
942
1029
  it (server.api_get): `/api/runs` (this run only), the RunDetail, every TaskDetail, every
943
1030
  ExecutionDetail the RunDetail names, every FinalReviewDetail, Files, RunFindings, and the
944
1031
  Fractal trees and nodes. With `texts=1` also every diff the page can open (each task's diff and its `sources`,
945
- each final review's, each Fractal node's) and every `dwarf` and `qa` ExecutionTranscript (at most 2 MB each).
1032
+ each final review's, each Fractal node's), every `dwarf` and `qa` ExecutionTranscript (at most 2 MB each),
1033
+ and the VerificationLog tail of `verification.log`, of each lane's log and of each lane run's log
1034
+ (under their tail paths; line ranges are not stored, so the page shows the tail).
946
1035
  - `texts=0` (the default) nulls these keys wherever they hold a string, list or object: `prompt
947
1036
  last request log_tail dwarf_tail qa_tail qa_last qa_input tasks_summary known_issues texts text
948
- planning_last approach retry_findings dwarf_input error`, and stores no diff and no transcript. A
949
- finding keeps its `summary`.
950
- - `paths=0` (the default) makes every string that is a whole absolute path (but `/api/…` and
951
- `url`s) its last component, and replaces the run dir, the repo and the home dir inside other
1037
+ planning_last approach retry_findings dwarf_input error last_line message excerpt tail lines`, and stores no
1038
+ diff and no transcript. A finding keeps its `summary`, a failing test its `name`.
1039
+ - `paths=0` (the default) makes every string that is a whole absolute path (but `/api/…`, `url`s
1040
+ and `command`s) its last component, and replaces the run dir, the repo and the home dir inside other
952
1041
  strings with their last component (`~` for home).
953
1042
  - At most 1 MB of JSON per diff and 25 MB in all; what did not fit is listed in `meta.omitted`
954
1043
  (`path`, `bytes`, `why`). `meta`: `format` `forge-ui-export`, `version` 1, `app`, `key`,
@@ -1012,9 +1101,12 @@ of the other mode: it replaces it as it does one running other code. `forge ui s
1012
1101
  mode (`read_only` in its JSON).
1013
1102
 
1014
1103
  **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
1104
+ of the shell it was started from. The CLIs it calls, for reads and writes alike, still need the forge configuration, so
1016
1105
  `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):
1106
+ `FORGE_REGISTRY` or `FORGE_DATA_HOME` is made absolute against the shell's directory, except that a
1107
+ `FORGE_DATA_HOME` starting with `~` is kept as given, because its reader expands the `~`; a
1108
+ `FORGE_REGISTRY` starting with `~` is not expanded by anything, so it is made absolute like any other
1109
+ relative path):
1018
1110
  `FORGE_REGISTRY`, `FORGE_DATA_HOME`, `FORGE_DATASET`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and
1019
1111
  `XDG_STATE_HOME` (`control.CLI_ENV_NAMES`). It writes them to `cli-env.json` (mode `0600`) in its
1020
1112
  state dir, the server loads that file once when it starts (`_serve --cli-env FILE`) and every CLI
@@ -1022,12 +1114,14 @@ call gets them on top of the server's own environment; the server's environment
1022
1114
  changed. No other variable is passed, and none of these is a secret. `server.json` records them as
1023
1115
  `cli_env`. A server started by hand (`python3 -m forge_ui`) passes its own environment unchanged.
1024
1116
 
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.
1117
+ `open` never reuses a server whose recorded `cli_env` differs from the current shell's. For a
1118
+ server that takes writes it names each variable that differs and both values, says to run
1119
+ `forge ui stop` and then open again, exits `5`, prints no link and leaves the server running (it may
1120
+ be in the middle of another shell's write). A read-only server (`FORGE_UI_READONLY=1`, asked for
1121
+ again in the same mode) has no write to cut off, so it is replaced, as is one running other code, in
1122
+ the other mode or reading another registry directory: the new one gets the current shell's
1123
+ configuration, `open` prints the link and exits `0`. Read-only servers still run CLIs for their GET
1124
+ routes, so their configuration matters too.
1031
1125
 
1032
1126
  **Serialization.** One write per run key at a time. A second write to a run whose write is still
1033
1127
  running is `409 {"error": "busy"}`, without reaching the CLI; other runs are not held up.
@@ -1056,7 +1150,14 @@ for them only unknown keys and runs the catalog does not hold are `404`. Every w
1056
1150
  `forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
1057
1151
  `run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
1058
1152
  `retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
1059
- is needed to read it.
1153
+ is needed to read it. Of `run` the page also reads `max_parallel` (1–64 or `null`), `infra_backoff`
1154
+ (the shape, `exp:60` when unset), `escalate_effort` (`on`/`off`), `drained` and `stopping`
1155
+ (`graceful`, `now` or `null`) while live, and `infra_paused` while live: one
1156
+ `{scope, class, harness, model, tasks, cycle, until, exhausted}` per paused scope (`scope` is
1157
+ `all`, `harness:<h>` or `model:<h>/<m>`; `until` an epoch, `exhausted` true once its retries are
1158
+ spent). `log` is the newest `control.log` lines first; a scheduler's answer to a queued request
1159
+ is a line with `actor: "scheduler"`, the request's `request` id, `outcome` (`applied` or
1160
+ `refused`) and `reason`.
1060
1161
 
1061
1162
  ### `POST /api/runs/<key>/controls`
1062
1163
 
@@ -1071,10 +1172,22 @@ known or a spec runs is for the CLI to say (its `400` or `409` comes back as abo
1071
1172
  | `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
1072
1173
  | `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
1073
1174
  | `{"action": "unpin", "task": "graph"}` | `control unpin <dir> graph --actor ui --json` |
1175
+ | `{"action": "drain"}` | `control drain <dir> --actor ui --json` |
1176
+ | `{"action": "resume"}` | `control resume <dir> --actor ui --json` |
1177
+ | `{"action": "stop"}` (or `"now": false`) | `control stop <dir> --actor ui --json` |
1178
+ | `{"action": "stop", "now": true}` | `control stop <dir> --now --actor ui --json` |
1179
+ | `{"action": "retry", "task": "graph"}` | `control retry <dir> graph --actor ui --json` |
1180
+ | `{"action": "retry", "task": "graph", "dwarf": "sol:high:codex"}` | `control retry <dir> graph --dwarf sol:high:codex --actor ui --json` |
1074
1181
 
1075
1182
  `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.
1183
+ for; the page sets `max-parallel`, `infra-backoff` (`none` drops it) and `escalate-effort`
1184
+ (`on`/`off`) plan-wide. `now` is a boolean. `add` is not exposed: a row is added to `tasks.tsv` by
1185
+ hand, so its CLI is the place to queue it. A success is
1186
+ `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where `controls` is the
1187
+ `GET` object after the change; `drain`, `resume` and `stop` answer the same way and are refused
1188
+ (`409`) when no scheduler is live. A `retry` only queues: `200 {"queued": true, "request": "<id>"}`.
1189
+ Its refusals before queueing (not live, a MERGED task, a spec that fails doctor) are the CLI's
1190
+ `409`/`400`; the scheduler applies or refuses it later, in the `log` line with that `request` id.
1078
1191
 
1079
1192
  ### `GET /api/runs/<key>/issues`
1080
1193
 
@@ -1175,9 +1288,10 @@ Plain scripts unless noted; each defines one global and none parses API text as
1175
1288
 
1176
1289
  | File | Global | What it is |
1177
1290
  |---|---|---|
1178
- | `runmodel.js` | `ForgeRunModel` | pure data, no DOM: the plate layout (archivision's `plate()`), the atlas, plan and execution models, forge-schedule's rules projected forward, the critical path, concurrency, the state of every node at a time `t` (replay), filters. Also a CommonJS module, for `node --test` |
1179
- | `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` |
1291
+ | `runmodel.js` | `ForgeRunModel` | pure data, no DOM: the plate layout (archivision's `plate()`), the atlas, plan and execution models, forge-schedule's rules projected forward, the critical path, concurrency, the state of every node at a time `t` (replay, verification lanes included), the finish node's verification LEDs (`verifyLeds`: one per lane in lanes mode, else one per run), filters. Also a CommonJS module, for `node --test` |
1292
+ | `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`. Under the attempts, the finish rows: `verify` (single-command verifications; the newest split into its runs back to back from its start, dashed because that placement is approximate), a `verify · <lane>` row per lane of a lanes verification (each run and rerun at its own time; a run with no exit is UNKNOWN once it ended, never FAIL) and `final review`. A lane or run item opens the verification inspector |
1180
1293
  | `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 |
1294
+ | `verifyview.js` | `ForgeVerify` | `render(host, {run, now, fetchJSON, rerender})`: the verification inspector panel.js delegates to: while it runs, the elapsed time, step, last line and a flag past 5 minutes without output, and a live tail of the followed lane's log (or the single log) read again each poll; when it failed, the failing tests first, grouped by lane with new or known, each opening its lane's log at its line (in an export, the tail); a lanes table (state, time, reruns, counts). Pure helpers (`model`, `laneRow`, `groupFailing`, `idleWarning`, `openStep`, `tailLane`, `logPath`, `linesPath`, `tailLines`) are a CommonJS module too |
1181
1295
  | `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}` |
1182
1296
  | `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 |
1183
1297
  | `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 |
@@ -43,6 +43,7 @@ _EXITS = {2: (400, 'invalid'), 3: (409, 'refused'), 4: (500, 'failed')}
43
43
  # of them is a secret (forge-config.py's SECRET_WORDS), and no other FORGE_* variable is ever passed.
44
44
  CLI_ENV_NAMES = ('FORGE_REGISTRY', 'FORGE_DATA_HOME', 'FORGE_DATASET', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME')
45
45
  _CLI_ENV_PATHS = ('FORGE_REGISTRY', 'FORGE_DATA_HOME') # read relative to the shell's directory, not the server's
46
+ _CLI_ENV_EXPANDS_HOME = ('FORGE_DATA_HOME',) # the paths whose reader expands a leading ~ itself
46
47
  cli_env = {} # set once, before the server starts; empty when it was started by hand
47
48
 
48
49
 
@@ -50,14 +51,15 @@ def cli_env_from(environ):
50
51
  """The CLI_ENV_NAMES set in `environ` (an empty value counts as unset, as it does for the CLIs).
51
52
 
52
53
  A relative FORGE_REGISTRY or FORGE_DATA_HOME is made absolute here: the server and its CLIs run in
53
- another directory than the shell that named it.
54
+ another directory than the shell that named it. A leading ~ is kept only where the reader expands it
55
+ (FORGE_DATA_HOME); a FORGE_REGISTRY of '~/x.tsv' is the relative path ./~/x.tsv to every CLI.
54
56
  """
55
57
  out = {}
56
58
  for name in CLI_ENV_NAMES:
57
59
  value = environ.get(name, '')
58
60
  if not value:
59
61
  continue
60
- if name in _CLI_ENV_PATHS and not value.startswith('~'):
62
+ if name in _CLI_ENV_PATHS and not (name in _CLI_ENV_EXPANDS_HOME and value.startswith('~')):
61
63
  value = os.path.abspath(value)
62
64
  out[name] = value
63
65
  return out
@@ -197,9 +199,13 @@ def validate_body(body):
197
199
  action = body.get('action')
198
200
  shapes = {'set': ({'action', 'key', 'value', 'task'}, ('key', 'value')),
199
201
  'pin': ({'action', 'task', 'spec'}, ('task', 'spec')),
200
- 'unpin': ({'action', 'task'}, ('task',))}
202
+ 'unpin': ({'action', 'task'}, ('task',)),
203
+ 'retry': ({'action', 'task', 'dwarf'}, ('task',)),
204
+ 'drain': ({'action'}, ()),
205
+ 'resume': ({'action'}, ()),
206
+ 'stop': ({'action', 'now'}, ())}
201
207
  if not isinstance(action, str) or action not in shapes:
202
- raise BadBody('action must be one of set, pin, unpin')
208
+ raise BadBody('action must be one of ' + ', '.join(shapes))
203
209
  allowed, required = shapes[action]
204
210
  extra = sorted(set(body) - allowed)
205
211
  if extra:
@@ -207,6 +213,12 @@ def validate_body(body):
207
213
  tail = [_text(body, name) for name in required]
208
214
  if action == 'set' and 'task' in body:
209
215
  tail += ['--task', _text(body, 'task')]
216
+ if action == 'retry' and 'dwarf' in body:
217
+ tail += ['--dwarf', _text(body, 'dwarf')]
218
+ if action == 'stop' and 'now' in body:
219
+ if not isinstance(body['now'], bool):
220
+ raise BadBody('now must be true or false')
221
+ tail += ['--now'] if body['now'] else []
210
222
  return action, tail
211
223
 
212
224
 
@@ -358,9 +370,16 @@ def _issues_file(ctx):
358
370
  if not isinstance(labels, list) or len(labels) > MAX_LABELS:
359
371
  raise BadBody(f'labels must be a list of at most {MAX_LABELS} strings')
360
372
  for label in labels:
361
- if (not isinstance(label, str) or not label.strip() or len(label) > MAX_LABEL
362
- or label.startswith('-') or not _no_control_chars(label)):
363
- raise BadBody(f'a label must be a text of 1 to {MAX_LABEL} characters, without control characters')
373
+ if not isinstance(label, str):
374
+ raise BadBody('a label must be a text')
375
+ if not label.strip():
376
+ raise BadBody('a label cannot be blank')
377
+ if len(label) > MAX_LABEL:
378
+ raise BadBody(f'a label is at most {MAX_LABEL} characters')
379
+ if label.startswith('-'):
380
+ raise BadBody('a label cannot start with "-"')
381
+ if not _no_control_chars(label):
382
+ raise BadBody('a label cannot hold control characters')
364
383
  except BadBody as exc:
365
384
  return _invalid(str(exc))
366
385
  path = ctx.locate()
@@ -22,11 +22,11 @@ import re
22
22
  import time
23
23
  import urllib.parse
24
24
 
25
- from forge_ui import __version__, server
25
+ from forge_ui import __version__, reader, server
26
26
 
27
27
  STATIC = server.STATIC_DIR
28
28
  # what the exported page runs, in index.html's order; the cloud, the leaderboard and notifications need a server
29
- SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
29
+ SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'verifyview.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
30
30
  'search.js', 'app.js')
31
31
  MODULES = ('iso.js', 'cloud3d.js', 'findings3d.js', 'stagectl.js', 'scene3d.js') # bundled in dependency order
32
32
  DIFF_CAP = 1 << 20 # one diff's JSON
@@ -37,7 +37,7 @@ _EXEC = re.compile(r'(?:task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
37
37
  # texts that can hold code, prompts or private notes: nulled unless texts=True
38
38
  _TEXT_KEYS = frozenset(('prompt', 'last', 'request', 'log_tail', 'dwarf_tail', 'qa_tail', 'qa_last', 'qa_input',
39
39
  'tasks_summary', 'known_issues', 'texts', 'text', 'planning_last', 'approach', 'retry_findings',
40
- 'dwarf_input', 'error'))
40
+ 'dwarf_input', 'error', 'last_line', 'message', 'excerpt', 'tail', 'lines'))
41
41
 
42
42
 
43
43
  def _enc(value):
@@ -113,12 +113,13 @@ class _Snapshot:
113
113
  if isinstance(value, list):
114
114
  return [self.clean(v, key) for v in value]
115
115
  if isinstance(value, str) and not self.paths and key != 'url':
116
- return self.hide(value)
116
+ return self.hide(value, key)
117
117
  return value
118
118
 
119
- def hide(self, text):
120
- # a whole absolute path becomes its last component; known roots inside longer texts are replaced too
121
- if re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
119
+ def hide(self, text, key=None):
120
+ # a whole absolute path becomes its last component; known roots inside longer texts are replaced too.
121
+ # A command that starts with a path is not one path: only its known roots go.
122
+ if key != 'command' and re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
122
123
  return os.path.basename(text.rstrip('/\\')) or text
123
124
  for root, name in self.roots:
124
125
  if root in text:
@@ -191,6 +192,18 @@ def build(catalog, key, texts=False, paths=False, now=None):
191
192
  for name in names:
192
193
  if name.startswith(('dwarf-', 'qa-')):
193
194
  snap.add(f'{base}/executions/{name}/transcript', cap=TRANSCRIPT_CAP)
195
+ # the verification's log tails: its own, each lane's and each lane run's (line ranges stay out)
196
+ verification = run.get('verification') or {}
197
+ if verification:
198
+ snap.add(f'{base}/verification/log')
199
+ for lane in verification.get('lanes') or []:
200
+ name = lane.get('name') if isinstance(lane, dict) else None
201
+ if isinstance(name, str) and reader.LANE_NAME.fullmatch(name):
202
+ snap.add(f'{base}/verification/lanes/{name}/log')
203
+ for item in lane.get('runs') or []:
204
+ k = item.get('k') if isinstance(item, dict) else None
205
+ if isinstance(k, int) and not isinstance(k, bool) and k > 0:
206
+ snap.add(f'{base}/verification/lanes/{name}/runs/{k}/log')
194
207
  meta = {'format': 'forge-ui-export', 'version': 1, 'app': __version__, 'key': key,
195
208
  'generated': now if now is not None else time.time(), 'texts': bool(texts), 'paths': bool(paths),
196
209
  'omitted': snap.omitted}