@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.
- package/CHANGELOG.md +34 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +44 -11
- package/docs/commands.md +45 -7
- 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-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-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 +18 -3
- package/ui/docs/api.md +126 -17
- package/ui/forge_ui/control.py +12 -2
- 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/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/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
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
|
949
|
-
finding keeps its `summary`.
|
|
950
|
-
- `paths=0` (the default) makes every string that is a whole absolute path (but `/api
|
|
951
|
-
`
|
|
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
|
|
1082
|
-
`
|
|
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 |
|
package/ui/forge_ui/control.py
CHANGED
|
@@ -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
|
|
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
|
|