@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.
- package/CHANGELOG.md +47 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +46 -12
- package/docs/commands.md +48 -9
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-issues.py +1 -1
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-ui.py +8 -4
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +19 -4
- package/ui/docs/api.md +139 -25
- package/ui/forge_ui/control.py +26 -7
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/findingsview.js +37 -12
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/settings.js +48 -12
- package/ui/forge_ui/static/style.css +60 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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,
|
|
16
|
-
many slots were busy against `--max-parallel
|
|
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
|
|
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).
|