@giamme/forge 0.11.1 → 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 (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  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-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -243,13 +243,25 @@ def _pct(v):
243
243
  return '%.0f%%' % (v * 100)
244
244
 
245
245
 
246
- def lines(fc: dict) -> list:
246
+ def _names(rows: list, most: int = 8) -> str:
247
+ names = [r['task'] for r in rows]
248
+ return ', '.join(names[:most]) + (', ...' if len(names) > most else '')
249
+
250
+
251
+ def lines(fc: dict, brief: bool = False) -> list:
252
+ """The forecast as text. brief (the block `run` prints) gives tasks without enough history
253
+ one line together instead of one row each, and no table at all when no task has enough."""
254
+ thin = [r for r in fc['tasks'] if not r['enough']]
255
+ if brief and len(thin) == len(fc['tasks']):
256
+ return ['no forecast yet: none of the %d task(s) has %d past tasks at its tier and model '
257
+ '(forge dataset forecast %s shows each count)' % (len(thin), MIN_HISTORY, fc['plan'])]
247
258
  out = ['forecast from %d past task(s) at the same tier and model (advisory; routing never reads it)'
248
259
  % fc['history_tasks']]
249
260
  for r in fc['tasks']:
250
261
  where = '%s %s' % (r['tier'] or '?', r['spec'] or '?')
251
262
  if not r['enough']:
252
- out.append(' %-14s %-22s %s' % (r['task'], where, r['note']))
263
+ if not brief:
264
+ out.append(' %-14s %-22s %s' % (r['task'], where, r['note']))
253
265
  continue
254
266
  fp = r['first_pass']
255
267
  t, s = r['tokens'], r['time_s']
@@ -257,6 +269,9 @@ def lines(fc: dict) -> list:
257
269
  r['task'], where, _pct(fp['rate']), _pct(fp['ci'][0]), _pct(fp['ci'][1]), fp['k'], fp['n'],
258
270
  _tokens(t['median']), _tokens(t['p90']), _time(s['median']), _time(s['p90']),
259
271
  ' <- likely to need a retry' if r['flag'] else ''))
272
+ if brief and thin:
273
+ out.append(' %d task(s) without enough history yet (under %d past tasks): %s'
274
+ % (len(thin), MIN_HISTORY, _names(thin)))
260
275
  if fc['merged']:
261
276
  out.append(' %d task(s) already merged, not forecast' % len(fc['merged']))
262
277
  tot = fc['total']
@@ -275,12 +290,17 @@ def lines(fc: dict) -> list:
275
290
  return out
276
291
 
277
292
 
293
+ def _tmp(plan: Path) -> Path:
294
+ """Where save() writes forecast.json before moving it into place."""
295
+ return plan / ('.forecast.json.%d' % os.getpid())
296
+
297
+
278
298
  def save(plan: Path, fc: dict) -> None:
279
299
  """Keep what was predicted, so it can be checked against what happened. Fail-open."""
280
300
  body = {k: v for k, v in fc.items() if k != 'at'}
281
301
  digest = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
302
+ tmp = _tmp(plan)
282
303
  try:
283
- tmp = plan / ('.forecast.json.%d' % os.getpid())
284
304
  tmp.write_text(json.dumps(fc, indent=2, sort_keys=True) + '\n')
285
305
  tmp.replace(plan / 'forecast.json')
286
306
  except OSError:
@@ -302,7 +322,7 @@ def advise(plan: Path, shown: list) -> None:
302
322
  return
303
323
  if not fc or not fc['tasks']:
304
324
  return
305
- for line in lines(fc):
325
+ for line in lines(fc, brief=True):
306
326
  print(line)
307
327
  sys.stdout.flush()
308
328
  shown.append(True)
@@ -336,6 +356,11 @@ def main(args) -> int:
336
356
  if not shown:
337
357
  print('forecast skipped: reading the history took over %gs' % budget())
338
358
  sys.stdout.flush()
359
+ # os._exit stops save() wherever it is, so a half-written temp file would stay in the plan.
360
+ try:
361
+ _tmp(plan).unlink()
362
+ except OSError:
363
+ pass
339
364
  os._exit(0)
340
365
  return 0
341
366
  if not (plan / 'tasks.tsv').is_file():
@@ -43,7 +43,7 @@ WATCHED = (
43
43
  'verification.status', 'verification.exit', 'verification.command', 'verification.log',
44
44
  'verification.retry.log', 'verification.flaky', 'verification.coverage.txt',
45
45
  'verification.jev.json', 'verification.runs.tsv', 'verification.commit', 'verification.fingerprint',
46
- 'results.tsv',
46
+ 'verification.lanes.json', 'results.tsv',
47
47
  )
48
48
 
49
49
  _VERDICT = re.compile(r'^FORGE_VERDICT: (PASS|FAIL)([ \t]+(—|–|-)( .*)?)?[ \t]*$')
@@ -544,8 +544,11 @@ def verification_events(attempt: Path, out: Path, rc, prov: dict, src: str = 'li
544
544
  repo = first_line(plan / 'repo')
545
545
  command = first_line(out / 'verification.command') if made('verification.command') else None
546
546
  jev = read_json(out / 'verification.jev.json') if made('verification.jev.json') else None
547
+ lanes = made('verification.lanes.json')
547
548
  if not command:
548
549
  source = None
550
+ elif lanes:
551
+ source = 'lanes:plan' if (plan / 'verify.d').is_dir() else 'lanes:repo'
549
552
  elif (plan / 'verify_cmd').exists():
550
553
  source = 'flag'
551
554
  elif jev and jev.get('discovered'):
@@ -559,9 +562,12 @@ def verification_events(attempt: Path, out: Path, rc, prov: dict, src: str = 'li
559
562
  for line in (text(out / 'verification.runs.tsv') or '').splitlines():
560
563
  cells = line.split('\t')
561
564
  if len(cells) >= 5 and cells[0].isdigit():
562
- runs.append(dict(n=int(cells[0]), kind=cells[1], exit=as_int(cells[2]),
563
- tree_changed=cells[3] == '1', duration_s=as_int(cells[4]),
564
- log=cells[5] if len(cells) > 5 else None))
565
+ run = dict(n=int(cells[0]), kind=cells[1], exit=as_int(cells[2]),
566
+ tree_changed=cells[3] == '1', duration_s=as_int(cells[4]),
567
+ log=cells[5] if len(cells) > 5 else None)
568
+ if len(cells) >= 8: # a lane's run: n kind exit tree_changed duration_s log lane started
569
+ run['lane'] = cells[6] or None
570
+ runs.append(run)
565
571
  elif command and made('verification.log'):
566
572
  runs.append(dict(n=1, kind='initial', exit=as_int(first_line(out / 'verification.exit')),
567
573
  tree_changed=None, duration_s=None, log='verification.log'))
@@ -0,0 +1,225 @@
1
+ """Test reports for forge-verify.py: JUnit XML and TAP 12-14. Stdlib only, offline.
2
+
3
+ ``scan(directory, since)`` reads every ``*.xml`` and ``*.tap`` under a lane's report
4
+ directory that was written at or after ``since`` (an epoch float: the start of that run),
5
+ recursively and without following symlinks, and returns the lane's ``tests``:
6
+
7
+ {"pass", "fail", "skip", "error",
8
+ "failing": [{"name", "suite", "file", "message", "excerpt", "known", "log_line"}],
9
+ "reports": [<paths relative to the directory>], "notes": [...], "truncated": bool}
10
+
11
+ plus what a baseline verdict needs and the display does not keep: every failing name (not
12
+ only the first 200) and whether the evidence is complete. A report that cannot be used
13
+ (too big, unreadable, not parseable, a DOCTYPE or ENTITY declaration) is skipped with a
14
+ note and makes the evidence incomplete; nothing here raises on a hostile file.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import re
20
+ import stat
21
+ import xml.etree.ElementTree as ET
22
+
23
+ FILE_MAX = 32 << 20
24
+ FAILING_MAX = 200
25
+ MESSAGE_MAX = 500
26
+ EXCERPT_MAX = 2000
27
+ LOG_SEARCH_MAX = 64 << 20
28
+
29
+ ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]')
30
+ # Refused before parsing: no external entities, no entity expansion bombs.
31
+ DECLARATIONS = re.compile(rb'<!\s*(?:DOCTYPE|ENTITY)', re.I)
32
+ TAP_LINE = re.compile(r'(not\s+)?ok\b\s*(\d+)?\s*(?:-\s*)?(.*)$')
33
+ TAP_PLAN = re.compile(r'1\.\.(\d+)\b')
34
+ TAP_BAIL = re.compile(r'Bail out!\s*(.*)$')
35
+ TAP_DIRECTIVE = re.compile(r'\s*(?<!\\)#\s*(SKIP|TODO)\b', re.I)
36
+
37
+
38
+ def clean(text: str | None, cap: int) -> str:
39
+ return ANSI.sub('', text or '').strip()[:cap]
40
+
41
+
42
+ def first_line(text: str | None) -> str:
43
+ for line in (text or '').splitlines():
44
+ if line.strip():
45
+ return line.strip()
46
+ return ''
47
+
48
+
49
+ def junit_cases(data: bytes) -> list[dict]:
50
+ """[{name, suite, outcome, message, excerpt}] from a JUnit XML document; ValueError when unusable."""
51
+ # The declaration check reads bytes, so only an ASCII-compatible encoding is let through:
52
+ # UTF-16 or UTF-32 (a byte-order mark, or NUL bytes) could hide a DOCTYPE from it.
53
+ if data[:2] in (b'\xff\xfe', b'\xfe\xff') or b'\x00' in data:
54
+ raise ValueError('only UTF-8 reports are read')
55
+ if DECLARATIONS.search(data):
56
+ raise ValueError('DOCTYPE or ENTITY declarations are refused')
57
+ try:
58
+ root = ET.fromstring(data)
59
+ except ET.ParseError as error:
60
+ raise ValueError('not well-formed XML (%s)' % error)
61
+ if root.tag not in ('testsuite', 'testsuites'):
62
+ raise ValueError('no testsuite or testsuites element')
63
+ parents = {child: parent for parent in root.iter() for child in parent}
64
+ cases = []
65
+ for case in root.iter('testcase'):
66
+ name, classname = case.get('name') or '?', case.get('classname')
67
+ suite = parents.get(case)
68
+ problem = case.find('failure')
69
+ outcome = 'fail'
70
+ if problem is None:
71
+ problem, outcome = case.find('error'), 'error'
72
+ if problem is None:
73
+ outcome = 'skip' if case.find('skipped') is not None else 'pass'
74
+ entry = {'name': '%s.%s' % (classname, name) if classname else name,
75
+ 'suite': suite.get('name') if suite is not None and suite.tag == 'testsuite' else None,
76
+ 'outcome': outcome, 'message': '', 'excerpt': ''}
77
+ if problem is not None:
78
+ text = problem.text or ''
79
+ entry['message'] = problem.get('message') or first_line(text)
80
+ parts = [text] + [(case.findtext(tag) or '') for tag in ('system-out', 'system-err')]
81
+ entry['excerpt'] = '\n'.join(p.strip('\n') for p in parts if p.strip())
82
+ cases.append(entry)
83
+ return cases
84
+
85
+
86
+ def tap_cases(data: bytes) -> tuple[list[dict], str | None]:
87
+ """Top-level test points of a TAP stream, each with its YAML diagnostic block if any, and
88
+ why the stream is incomplete (a `Bail out!`, no plan, or fewer or more points than its
89
+ plan), else None. An incomplete stream's points still count, but cannot prove a baseline."""
90
+ try:
91
+ text = data.decode('utf-8')
92
+ except UnicodeDecodeError:
93
+ raise ValueError('not UTF-8 text')
94
+ cases: list[dict] = []
95
+ block: list[str] | None = None
96
+ planned = bail = None
97
+ for line in text.splitlines():
98
+ if block is not None:
99
+ if line.strip() == '...':
100
+ if cases:
101
+ diag = '\n'.join(block)
102
+ cases[-1]['excerpt'] = diag
103
+ found = re.search(r'^\s*message:\s*(.*)$', diag, re.M)
104
+ if found:
105
+ cases[-1]['message'] = found.group(1).strip().strip('\'"')
106
+ block = None
107
+ else:
108
+ block.append(line)
109
+ continue
110
+ if line.strip() == '---' and line.startswith(' ') and cases:
111
+ block = []
112
+ continue
113
+ stop = TAP_BAIL.match(line)
114
+ if stop:
115
+ bail = stop.group(1).strip() or 'no reason given'
116
+ break
117
+ plan = TAP_PLAN.match(line)
118
+ if plan and planned is None:
119
+ planned = int(plan.group(1))
120
+ continue
121
+ match = TAP_LINE.match(line) # top level only: subtests are indented
122
+ if not match:
123
+ continue
124
+ description = match.group(3)
125
+ directive = TAP_DIRECTIVE.search(description)
126
+ name = description[:directive.start()].strip() if directive else description.strip()
127
+ if directive:
128
+ outcome = 'skip'
129
+ else:
130
+ outcome = 'fail' if match.group(1) else 'pass'
131
+ cases.append({'name': name or 'test %s' % (match.group(2) or len(cases) + 1), 'suite': None,
132
+ 'outcome': outcome, 'message': '', 'excerpt': ''})
133
+ if not cases:
134
+ raise ValueError('no TAP test points')
135
+ if bail is not None:
136
+ return cases, 'it bailed out (%s)' % clean(bail, MESSAGE_MAX)
137
+ if planned is None:
138
+ return cases, 'it has no plan line (1..N)'
139
+ if planned != len(cases):
140
+ return cases, 'its plan says %d tests and it has %d' % (planned, len(cases))
141
+ return cases, None
142
+
143
+
144
+ def scan(directory: str, since: float) -> tuple[dict, list[str], bool]:
145
+ """(tests, every failing name, whether the evidence is complete) for one lane run."""
146
+ tests = {'pass': 0, 'fail': 0, 'skip': 0, 'error': 0, 'failing': [], 'reports': [],
147
+ 'notes': [], 'truncated': False}
148
+ names: list[str] = []
149
+ complete = True
150
+ found = []
151
+ for top, dirs, files in os.walk(directory, followlinks=False):
152
+ dirs.sort()
153
+ for name in sorted(files):
154
+ if name.endswith(('.xml', '.tap')):
155
+ found.append(os.path.join(top, name))
156
+ for path in found:
157
+ rel = os.path.relpath(path, directory)
158
+ try:
159
+ info = os.lstat(path)
160
+ except OSError:
161
+ continue
162
+ if not stat.S_ISREG(info.st_mode) or info.st_mtime < since:
163
+ continue # a symlink, or a report an earlier run left
164
+ try:
165
+ if info.st_size > FILE_MAX:
166
+ raise ValueError('larger than %d MB' % (FILE_MAX >> 20))
167
+ with open(path, 'rb') as handle:
168
+ data = handle.read(FILE_MAX + 1)
169
+ if len(data) > FILE_MAX:
170
+ raise ValueError('larger than %d MB' % (FILE_MAX >> 20))
171
+ cases, problem = (junit_cases(data), None) if rel.endswith('.xml') else tap_cases(data)
172
+ except (OSError, ValueError, RecursionError) as error:
173
+ tests['notes'].append('%s skipped: %s' % (rel, error))
174
+ complete = False
175
+ continue
176
+ tests['reports'].append(rel)
177
+ if problem:
178
+ tests['notes'].append('%s is incomplete: %s' % (rel, problem))
179
+ complete = False
180
+ for case in cases:
181
+ tests[case['outcome']] += 1
182
+ if case['outcome'] not in ('fail', 'error'):
183
+ continue
184
+ names.append(case['name'])
185
+ if len(tests['failing']) >= FAILING_MAX:
186
+ tests['truncated'] = True
187
+ continue
188
+ tests['failing'].append({'name': clean(case['name'], MESSAGE_MAX), 'suite': case['suite'],
189
+ 'file': rel, 'message': clean(case['message'], MESSAGE_MAX),
190
+ 'excerpt': clean(case['excerpt'], EXCERPT_MAX),
191
+ 'known': False, 'log_line': None})
192
+ return tests, names, complete
193
+
194
+
195
+ def log_lines(log: str, names: list[str]) -> dict:
196
+ """{name: 1-based number of the first log line containing it}, within the first 64 MB."""
197
+ wanted = sorted({n for n in names if n}, key=len, reverse=True)
198
+ if not wanted:
199
+ return {}
200
+ pattern = re.compile('|'.join(re.escape(n) for n in wanted))
201
+ found: dict = {}
202
+ read = 0
203
+ try:
204
+ with open(log, 'rb') as handle:
205
+ for number, raw in enumerate(handle, 1):
206
+ read += len(raw)
207
+ if read > LOG_SEARCH_MAX:
208
+ break
209
+ line = raw.decode('utf-8', 'replace')
210
+ if not pattern.search(line): # one search per line; most lines name no test
211
+ continue
212
+ for name in wanted:
213
+ if name not in found and name in line:
214
+ found[name] = number
215
+ if len(found) == len(wanted):
216
+ break
217
+ except OSError:
218
+ pass
219
+ return found
220
+
221
+
222
+ def load_baseline(path: str) -> set:
223
+ """Known failing test names: one per line, blank lines and # comments ignored."""
224
+ with open(path, encoding='utf-8', errors='replace') as handle:
225
+ return {line.strip() for line in handle if line.strip() and not line.strip().startswith('#')}
package/ui/README.md CHANGED
@@ -12,9 +12,12 @@ blocks on a plate, with cables between them and lamps that light up as things ha
12
12
  dependency cables, and ends in a *finish* line of three nodes: integration, verification
13
13
  and the whole-run **final review**. *Execution* lays the same work out on wall-clock time:
14
14
  one block per attempt, in the lane it really ran in, with its dwarf and review inside, the
15
- attempts that have not started projected by forge-schedule's own rules, and a strip of how
16
- many slots were busy against `--max-parallel`. Switching between the two moves each task
17
- from its wave to where it ran.
15
+ attempts that have not started projected by forge-schedule's own rules, a strip of how
16
+ many slots were busy against `--max-parallel`, and the verification's lanes and reruns on
17
+ rows of their own. Switching between the two moves each task from its wave to where it ran.
18
+ - **Verification as it happens:** the step each lane is in, its last line, the failing tests
19
+ first (new or known, from the lanes' test reports) with a link into the log at each one, and
20
+ every lane's output a click away.
18
21
  - **Click to open.** A task opens to show its attempts, each with its dwarf, its reviewer and
19
22
  any shadow reviews, linked; an escalation is a labelled cable between attempts. The
20
23
  inspector beside the stage shows what launched each step and what came of it: the exact
@@ -178,6 +181,18 @@ keep one meaning everywhere (amber running, green merged or passed, red failed,
178
181
  with the gate decision, Clef predictions against outcomes, and its prompts; for one
179
182
  execution, its model, phases, tokens, exact prompt and final message; for the final
180
183
  review, its verdict, findings, report, prompt and history.
184
+ - **Verification (its finish node):** while it runs, how long it has run, the step a lane
185
+ announced (`::forge step name=… state=start`) and for how long, the last line it printed, a
186
+ warning after five minutes with no output, and the live output of the lane that printed last
187
+ (choose a lane to follow it instead). Once it failed, the failing tests come first, grouped by
188
+ lane and marked *new* or *known* (from the lane's JUnit or TAP reports and its baseline), each
189
+ with its message, an excerpt and *open in log* at its line; a lane without reports shows the
190
+ end of its log, and the step it ended in is named. A table lists every lane with its state
191
+ (flaky and known marked), time, runs and test counts; a lane's name opens its output. A
192
+ changed checkout is called out. A single verify command shows the newest of its logs live.
193
+ In *Execution*, the verification gets rows after the tasks: one per lane, with a bar per run
194
+ and reruns marked, or a single command's runs back to back (dashed: only their durations are
195
+ recorded). The finish node counts the lanes and reruns and lights one lamp per lane.
181
196
  - **Files (the run toolbar's third mode):** a heatmap of files (grouped and collapsible by
182
197
  directory) against tasks (grouped by wave): planned files outlined, touched files filled by how
183
198
  many attempts changed them, drift hatched, merge conflicts ringed, and a dot where another task
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`,
@@ -1061,7 +1150,14 @@ for them only unknown keys and runs the catalog does not hold are `404`. Every w
1061
1150
  `forge control show <plan dir> --json`: `200` with its object (`plan`, `run_id`, `live`, `fractal`,
1062
1151
  `run` (the plan-wide controls), `tasks` (per task: `status`, `dwarf`, `qa`, `qa_threshold`,
1063
1152
  `retry_budget`, `retries_used`, `pin`, `escalation_stop`, `escalation_next`) and `log`). No token
1064
- 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`.
1065
1161
 
1066
1162
  ### `POST /api/runs/<key>/controls`
1067
1163
 
@@ -1076,10 +1172,22 @@ known or a spec runs is for the CLI to say (its `400` or `409` comes back as abo
1076
1172
  | `{"action": "set", "key": "retry-budget", "value": "inherit", "task": "reader"}` | `control set <dir> retry-budget inherit --task reader --actor ui --json` |
1077
1173
  | `{"action": "pin", "task": "graph", "spec": "sol:high:codex"}` | `control pin <dir> graph sol:high:codex --actor ui --json` |
1078
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` |
1079
1181
 
1080
1182
  `value` is always a string. `task` is optional on `set`, and only for the keys the CLI allows it
1081
- for. A success is `200 {"ok": true, "changed": {...}, "applies": "...", "controls": {...}}`, where
1082
- `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.
1083
1191
 
1084
1192
  ### `GET /api/runs/<key>/issues`
1085
1193
 
@@ -1180,9 +1288,10 @@ Plain scripts unless noted; each defines one global and none parses API text as
1180
1288
 
1181
1289
  | File | Global | What it is |
1182
1290
  |---|---|---|
1183
- | `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` |
1184
- | `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 |
1185
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 |
1186
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}` |
1187
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 |
1188
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 |
@@ -199,9 +199,13 @@ def validate_body(body):
199
199
  action = body.get('action')
200
200
  shapes = {'set': ({'action', 'key', 'value', 'task'}, ('key', 'value')),
201
201
  'pin': ({'action', 'task', 'spec'}, ('task', 'spec')),
202
- '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'}, ())}
203
207
  if not isinstance(action, str) or action not in shapes:
204
- raise BadBody('action must be one of set, pin, unpin')
208
+ raise BadBody('action must be one of ' + ', '.join(shapes))
205
209
  allowed, required = shapes[action]
206
210
  extra = sorted(set(body) - allowed)
207
211
  if extra:
@@ -209,6 +213,12 @@ def validate_body(body):
209
213
  tail = [_text(body, name) for name in required]
210
214
  if action == 'set' and 'task' in body:
211
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 []
212
222
  return action, tail
213
223
 
214
224