@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
@@ -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
@@ -266,7 +281,7 @@ takes effect. The routes are in [docs/api.md](docs/api.md).
266
281
  infra-retry budgets. A task has a threshold override (`Inherit`, P0–P3 or `None`, which
267
282
  exempts it from the run's threshold), its own retry budget and a **model pin**: the model
268
283
  its next full retry uses, once. A control that cannot apply (no live scheduler, a merged
269
- task, a Fractal plan's pin) is disabled and says why.
284
+ task) is disabled and says why. A Fractal plan's pin control is hidden.
270
285
  - **Open findings (`forge issues`):** the findings a run, or every run of its repository,
271
286
  left open. The list opens from a quick offline listing and then fills in what is already
272
287
  filed; select findings to file as GitHub issues, or dismiss them (and undo that).