@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
package/ui/forge_ui/export.py
CHANGED
|
@@ -22,11 +22,11 @@ import re
|
|
|
22
22
|
import time
|
|
23
23
|
import urllib.parse
|
|
24
24
|
|
|
25
|
-
from forge_ui import __version__, server
|
|
25
|
+
from forge_ui import __version__, reader, server
|
|
26
26
|
|
|
27
27
|
STATIC = server.STATIC_DIR
|
|
28
28
|
# what the exported page runs, in index.html's order; the cloud, the leaderboard and notifications need a server
|
|
29
|
-
SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
|
|
29
|
+
SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'verifyview.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
|
|
30
30
|
'search.js', 'app.js')
|
|
31
31
|
MODULES = ('iso.js', 'cloud3d.js', 'findings3d.js', 'stagectl.js', 'scene3d.js') # bundled in dependency order
|
|
32
32
|
DIFF_CAP = 1 << 20 # one diff's JSON
|
|
@@ -37,7 +37,7 @@ _EXEC = re.compile(r'(?:task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
|
|
|
37
37
|
# texts that can hold code, prompts or private notes: nulled unless texts=True
|
|
38
38
|
_TEXT_KEYS = frozenset(('prompt', 'last', 'request', 'log_tail', 'dwarf_tail', 'qa_tail', 'qa_last', 'qa_input',
|
|
39
39
|
'tasks_summary', 'known_issues', 'texts', 'text', 'planning_last', 'approach', 'retry_findings',
|
|
40
|
-
'dwarf_input', 'error'))
|
|
40
|
+
'dwarf_input', 'error', 'last_line', 'message', 'excerpt', 'tail', 'lines'))
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _enc(value):
|
|
@@ -113,12 +113,13 @@ class _Snapshot:
|
|
|
113
113
|
if isinstance(value, list):
|
|
114
114
|
return [self.clean(v, key) for v in value]
|
|
115
115
|
if isinstance(value, str) and not self.paths and key != 'url':
|
|
116
|
-
return self.hide(value)
|
|
116
|
+
return self.hide(value, key)
|
|
117
117
|
return value
|
|
118
118
|
|
|
119
|
-
def hide(self, text):
|
|
120
|
-
# a whole absolute path becomes its last component; known roots inside longer texts are replaced too
|
|
121
|
-
|
|
119
|
+
def hide(self, text, key=None):
|
|
120
|
+
# a whole absolute path becomes its last component; known roots inside longer texts are replaced too.
|
|
121
|
+
# A command that starts with a path is not one path: only its known roots go.
|
|
122
|
+
if key != 'command' and re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
|
|
122
123
|
return os.path.basename(text.rstrip('/\\')) or text
|
|
123
124
|
for root, name in self.roots:
|
|
124
125
|
if root in text:
|
|
@@ -191,6 +192,18 @@ def build(catalog, key, texts=False, paths=False, now=None):
|
|
|
191
192
|
for name in names:
|
|
192
193
|
if name.startswith(('dwarf-', 'qa-')):
|
|
193
194
|
snap.add(f'{base}/executions/{name}/transcript', cap=TRANSCRIPT_CAP)
|
|
195
|
+
# the verification's log tails: its own, each lane's and each lane run's (line ranges stay out)
|
|
196
|
+
verification = run.get('verification') or {}
|
|
197
|
+
if verification:
|
|
198
|
+
snap.add(f'{base}/verification/log')
|
|
199
|
+
for lane in verification.get('lanes') or []:
|
|
200
|
+
name = lane.get('name') if isinstance(lane, dict) else None
|
|
201
|
+
if isinstance(name, str) and reader.LANE_NAME.fullmatch(name):
|
|
202
|
+
snap.add(f'{base}/verification/lanes/{name}/log')
|
|
203
|
+
for item in lane.get('runs') or []:
|
|
204
|
+
k = item.get('k') if isinstance(item, dict) else None
|
|
205
|
+
if isinstance(k, int) and not isinstance(k, bool) and k > 0:
|
|
206
|
+
snap.add(f'{base}/verification/lanes/{name}/runs/{k}/log')
|
|
194
207
|
meta = {'format': 'forge-ui-export', 'version': 1, 'app': __version__, 'key': key,
|
|
195
208
|
'generated': now if now is not None else time.time(), 'texts': bool(texts), 'paths': bool(paths),
|
|
196
209
|
'omitted': snap.omitted}
|
package/ui/forge_ui/reader.py
CHANGED
|
@@ -9,6 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
import posixpath
|
|
10
10
|
import re
|
|
11
11
|
import stat
|
|
12
|
+
import time
|
|
12
13
|
import urllib.parse
|
|
13
14
|
|
|
14
15
|
from forge_ui import diffs, executions, findings, fractal, transcripts
|
|
@@ -604,7 +605,10 @@ def _task_detail(reader, task, run_id, waves, branches):
|
|
|
604
605
|
|
|
605
606
|
# The control files (CONTRACT.md) nothing else here reads. `forge control` appends control.log and writes the task's
|
|
606
607
|
# files without touching the run dir, so they are stat'd for `updated` only.
|
|
607
|
-
|
|
608
|
+
# Read only for their mtimes, so the run's `updated` stamp moves when a control changes.
|
|
609
|
+
# control.requests is a directory: its mtime moves when a request is queued or settled.
|
|
610
|
+
_RUN_CONTROLS = ('control.log', 'retry_budget', 'infra_budget', 'drain', 'stop', 'max_parallel', 'infra.paused',
|
|
611
|
+
'infra_backoff', 'escalate_effort', 'control.requests')
|
|
608
612
|
_TASK_CONTROLS = ('qa_threshold', 'retry_budget', 'retries.used', 'escalation.pin', 'escalation.stop', 'escalation.next')
|
|
609
613
|
|
|
610
614
|
|
|
@@ -797,21 +801,210 @@ def _final_review(reader, item, newest, published):
|
|
|
797
801
|
|
|
798
802
|
|
|
799
803
|
def _verification_runs(reader):
|
|
804
|
+
"""6 columns from a single command, 8 (adding lane and started) from lanes."""
|
|
800
805
|
runs = []
|
|
801
806
|
for label, row in reader.rows('verification.runs.tsv', 6):
|
|
802
807
|
try:
|
|
808
|
+
if len(row) not in (6, 8):
|
|
809
|
+
raise ValueError()
|
|
803
810
|
n, duration = int(row[0]), float(row[4])
|
|
804
811
|
code = int(row[2]) if row[2].strip() else None
|
|
805
|
-
|
|
812
|
+
started = float(row[7]) if len(row) == 8 else None
|
|
813
|
+
if row[3].strip() not in ('0', '1') or not math.isfinite(duration) \
|
|
814
|
+
or (started is not None and not math.isfinite(started)):
|
|
806
815
|
raise ValueError()
|
|
807
816
|
except (ValueError, OverflowError):
|
|
808
817
|
reader.warn(label, 'invalid verification run')
|
|
809
818
|
continue
|
|
810
819
|
runs.append(dict(n=n, kind=row[1], exit=code, tree_changed=row[3].strip() == '1',
|
|
811
|
-
duration_s=duration, log=row[5].strip() or None
|
|
820
|
+
duration_s=duration, log=row[5].strip() or None,
|
|
821
|
+
lane=(row[6].strip() or None) if len(row) == 8 else None, started=started))
|
|
812
822
|
return runs
|
|
813
823
|
|
|
814
824
|
|
|
825
|
+
# --- live verification and lanes -----------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
_LANES_MAX = 4 << 20 # verification.lanes.json beyond this is not read
|
|
828
|
+
_LIVE_TAIL = 65536 # what is read of a single-command log for its step and last line
|
|
829
|
+
_FAILING_MAX = 200
|
|
830
|
+
_LAST_LINE_MAX = 300
|
|
831
|
+
_STEP = re.compile(r'::forge step name=(\S+) state=(start|end)(?: exit=(-?\d+))?')
|
|
832
|
+
_ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]')
|
|
833
|
+
_LANE_STATES = ('waiting', 'running', 'pass', 'fail', 'flaky', 'known', 'skipped', 'interrupted')
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _finite(value):
|
|
837
|
+
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) \
|
|
838
|
+
and math.isfinite(value) else None
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _str(value):
|
|
842
|
+
return value if isinstance(value, str) else None
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _last_line(text):
|
|
846
|
+
for line in reversed((text or '').splitlines()):
|
|
847
|
+
line = _ANSI.sub('', line).strip()
|
|
848
|
+
if line:
|
|
849
|
+
return line[:_LAST_LINE_MAX]
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _open_step(text):
|
|
854
|
+
"""The last step marker started and not yet ended in the text, or None."""
|
|
855
|
+
open_steps = []
|
|
856
|
+
for line in (text or '').splitlines():
|
|
857
|
+
match = _STEP.fullmatch(_ANSI.sub('', line).strip())
|
|
858
|
+
if not match:
|
|
859
|
+
continue
|
|
860
|
+
if match.group(2) == 'start':
|
|
861
|
+
open_steps.append(match.group(1))
|
|
862
|
+
elif match.group(1) in open_steps:
|
|
863
|
+
del open_steps[len(open_steps) - 1 - open_steps[::-1].index(match.group(1))]
|
|
864
|
+
return open_steps[-1] if open_steps else None
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _lanes_doc(reader):
|
|
868
|
+
"""(the lanes document, its stat) or (None, stat): a missing, oversized or invalid file
|
|
869
|
+
is warned about and never fails the request."""
|
|
870
|
+
name = 'verification.lanes.json'
|
|
871
|
+
info = reader.stat(name)
|
|
872
|
+
if info is None:
|
|
873
|
+
return None, None
|
|
874
|
+
if info.st_size > _LANES_MAX:
|
|
875
|
+
reader.warn(name, 'larger than 4 MB, not read')
|
|
876
|
+
return None, info
|
|
877
|
+
doc = reader.object(name)
|
|
878
|
+
if doc is not None and not isinstance(doc.get('lanes'), list):
|
|
879
|
+
reader.warn(name, 'no lanes list')
|
|
880
|
+
doc = None
|
|
881
|
+
return doc, info
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _items(value):
|
|
885
|
+
"""A JSON list, or nothing in place of anything else."""
|
|
886
|
+
return value if isinstance(value, list) else []
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _tests(reader, value, field):
|
|
890
|
+
if not isinstance(value, dict):
|
|
891
|
+
return None
|
|
892
|
+
counts = {key: int(value[key]) if isinstance(value.get(key), int) and not isinstance(value.get(key), bool)
|
|
893
|
+
else 0 for key in ('pass', 'fail', 'skip', 'error')}
|
|
894
|
+
failing = []
|
|
895
|
+
for entry in value.get('failing') if isinstance(value.get('failing'), list) else []:
|
|
896
|
+
if not isinstance(entry, dict) or len(failing) >= _FAILING_MAX:
|
|
897
|
+
continue
|
|
898
|
+
line = entry.get('log_line')
|
|
899
|
+
failing.append(dict(name=reader.cap(_str(entry.get('name')), field + '.name', 500),
|
|
900
|
+
suite=_str(entry.get('suite')), file=_str(entry.get('file')),
|
|
901
|
+
message=reader.cap(_str(entry.get('message')), field + '.message', 500),
|
|
902
|
+
excerpt=reader.cap(_str(entry.get('excerpt')), field + '.excerpt', 2000),
|
|
903
|
+
known=entry.get('known') is True,
|
|
904
|
+
log_line=line if isinstance(line, int) and not isinstance(line, bool) and line > 0 else None))
|
|
905
|
+
return dict(counts, failing=failing,
|
|
906
|
+
reports=[r for r in _items(value.get('reports')) if isinstance(r, str)][:200],
|
|
907
|
+
notes=[reader.cap(n, field + '.notes', 500) for n in _items(value.get('notes')) if isinstance(n, str)][:50],
|
|
908
|
+
truncated=value.get('truncated') is True)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _lanes(reader, doc, now):
|
|
912
|
+
lanes = []
|
|
913
|
+
for index, lane in enumerate(doc['lanes']):
|
|
914
|
+
if not isinstance(lane, dict) or not _str(lane.get('name')):
|
|
915
|
+
reader.warn('verification.lanes.json', f'lane {index + 1} is not a lane')
|
|
916
|
+
continue
|
|
917
|
+
name = lane['name']
|
|
918
|
+
started, ended = _finite(lane.get('started')), _finite(lane.get('ended'))
|
|
919
|
+
state = lane.get('state') if lane.get('state') in _LANE_STATES else _str(lane.get('state'))
|
|
920
|
+
last_output = _finite(lane.get('last_output_at'))
|
|
921
|
+
step = lane.get('step') if isinstance(lane.get('step'), dict) else None
|
|
922
|
+
runs = []
|
|
923
|
+
for run in lane.get('runs') if isinstance(lane.get('runs'), list) else []:
|
|
924
|
+
if isinstance(run, dict):
|
|
925
|
+
runs.append(dict(k=run.get('k') if isinstance(run.get('k'), int) else None, kind=_str(run.get('kind')),
|
|
926
|
+
started=_finite(run.get('started')), ended=_finite(run.get('ended')),
|
|
927
|
+
exit=run.get('exit') if isinstance(run.get('exit'), int) else None,
|
|
928
|
+
log=_str(run.get('log'))))
|
|
929
|
+
options = lane.get('options') if isinstance(lane.get('options'), dict) else {}
|
|
930
|
+
lanes.append(dict(
|
|
931
|
+
name=name, command=reader.cap(_str(lane.get('command')), f'verification.lanes.{name}.command'),
|
|
932
|
+
group=_str(lane.get('group')), first=lane.get('first') is True,
|
|
933
|
+
retries=options.get('retries') if isinstance(options.get('retries'), int) else None,
|
|
934
|
+
options={k: v for k, v in options.items() if isinstance(v, (str, int)) and not isinstance(v, bool)},
|
|
935
|
+
state=state, exit=lane.get('exit') if isinstance(lane.get('exit'), int) else None,
|
|
936
|
+
started=started, ended=ended,
|
|
937
|
+
duration_s=((ended if ended is not None else now) - started) if started is not None else None,
|
|
938
|
+
runs=runs,
|
|
939
|
+
step=dict(name=_str(step.get('name')), started=_finite(step.get('started'))) if step else None,
|
|
940
|
+
last_line=reader.cap(_str(lane.get('last_line')), f'verification.lanes.{name}.last_line', _LAST_LINE_MAX),
|
|
941
|
+
last_output_at=last_output,
|
|
942
|
+
idle_s=(now - last_output) if state == 'running' and last_output is not None else None,
|
|
943
|
+
log=_str(lane.get('log')),
|
|
944
|
+
tests=_tests(reader, lane.get('tests'), f'verification.lanes.{name}.tests')))
|
|
945
|
+
return lanes
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _live_single(reader, started, now):
|
|
949
|
+
"""The running command's live state from whichever of its two logs moved last: a rerun
|
|
950
|
+
writes verification.retry.log."""
|
|
951
|
+
logs = [(info.st_mtime, name) for name in ('verification.log', 'verification.retry.log')
|
|
952
|
+
for info in [reader.stat(name)] if info is not None]
|
|
953
|
+
if not logs:
|
|
954
|
+
return dict(started=started, step=None, last_line=None, last_output_at=None,
|
|
955
|
+
idle_s=(now - started) if started is not None else None, log=None)
|
|
956
|
+
mtime, name = max(logs)
|
|
957
|
+
text = reader.text(name, _LIVE_TAIL)
|
|
958
|
+
step = _open_step(text)
|
|
959
|
+
return dict(started=started, step=dict(name=step, started=None) if step else None,
|
|
960
|
+
last_line=_last_line(text), last_output_at=float(mtime), idle_s=max(0.0, now - mtime), log=name)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _live_lanes(lanes, started, now):
|
|
964
|
+
"""The running lane that printed most recently speaks for the verification."""
|
|
965
|
+
running = [lane for lane in lanes if lane['state'] == 'running']
|
|
966
|
+
printed = [lane for lane in running if lane['last_output_at'] is not None]
|
|
967
|
+
lane = max(printed, key=lambda item: item['last_output_at']) if printed else (running[0] if running else None)
|
|
968
|
+
last = lane['last_output_at'] if lane else None
|
|
969
|
+
return dict(started=started, step=lane['step'] if lane else None, last_line=lane['last_line'] if lane else None,
|
|
970
|
+
last_output_at=last, idle_s=max(0.0, now - (last if last is not None else started))
|
|
971
|
+
if (last is not None or started is not None) else None, log=None)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _verification(reader, status, verifications):
|
|
975
|
+
"""RunDetail.verification: the result files, plus whether it is running now, in which
|
|
976
|
+
mode, its live state, its lanes and every failing test."""
|
|
977
|
+
now = time.time()
|
|
978
|
+
newest = max(verifications, key=lambda item: (item['started'] is not None, item['started'] or 0.0), default=None)
|
|
979
|
+
state = None if newest is None else 'running' if newest['state'] == 'running' else 'done'
|
|
980
|
+
started = newest['started'] if newest is not None else None
|
|
981
|
+
doc, info = _lanes_doc(reader)
|
|
982
|
+
lanes_file = info is not None
|
|
983
|
+
mode = 'lanes' if lanes_file and (started is None or info.st_mtime >= started) else 'single'
|
|
984
|
+
command = reader.text('verification.command')
|
|
985
|
+
log_tail = reader.cap(reader.text('verification.log', 65536), 'verification.log_tail', 4000, True)
|
|
986
|
+
present = any('verification.' + suffix in reader.present_files for suffix in ('status', 'command', 'log'))
|
|
987
|
+
if not (present or state or lanes_file):
|
|
988
|
+
return None
|
|
989
|
+
lanes = _lanes(reader, doc, now) if mode == 'lanes' and doc is not None else None
|
|
990
|
+
if state == 'running':
|
|
991
|
+
live = _live_lanes(lanes or [], started, now) if mode == 'lanes' else _live_single(reader, started, now)
|
|
992
|
+
else:
|
|
993
|
+
live = None
|
|
994
|
+
failing = []
|
|
995
|
+
for lane in lanes or []:
|
|
996
|
+
for entry in (lane['tests'] or {}).get('failing', []):
|
|
997
|
+
if len(failing) < _FAILING_MAX:
|
|
998
|
+
failing.append(dict(entry, lane=lane['name']))
|
|
999
|
+
runs = _verification_runs(reader)
|
|
1000
|
+
# A lane or a run of the command changed the checkout: later reruns were skipped.
|
|
1001
|
+
mutated = doc.get('mutated') is True if lanes is not None else any(run['tree_changed'] for run in runs)
|
|
1002
|
+
return dict(status='RUNNING' if state == 'running' else status,
|
|
1003
|
+
command=command.strip() if command is not None else None,
|
|
1004
|
+
log_tail=log_tail, runs=runs, commit=reader.line('verification.commit'),
|
|
1005
|
+
state=state, mode=mode, live=live, lanes=lanes, failing=failing, mutated=mutated)
|
|
1006
|
+
|
|
1007
|
+
|
|
815
1008
|
def _integration(reader, tasks, integrations, reviews, published):
|
|
816
1009
|
base, accepted = reader.line('integration.base'), reader.line('accepted.integration')
|
|
817
1010
|
merged = sorted((dict(id=task['id'], at=task['merged_at']) for task in tasks if task['merged_at'] is not None),
|
|
@@ -901,14 +1094,6 @@ def detail(path, source='plan') -> dict:
|
|
|
901
1094
|
max_parallel=reader.number('max_parallel', int), qa_threshold=reader.line('qa_threshold'),
|
|
902
1095
|
escalate_after=reader.number('escalate_after', int),
|
|
903
1096
|
verify_retries=reader.number('verify_retries', int), final_qa=reader.line('final_qa'))
|
|
904
|
-
command = reader.text('verification.command')
|
|
905
|
-
verification = dict(status=result['verification_status'], command=command.strip() if command is not None else None,
|
|
906
|
-
log_tail=reader.cap(reader.text('verification.log', 65536), 'verification.log_tail', 4000, True))
|
|
907
|
-
if any('verification.' + suffix in reader.present_files for suffix in ('status', 'command', 'log')):
|
|
908
|
-
verification.update(runs=_verification_runs(reader), commit=reader.line('verification.commit'))
|
|
909
|
-
result['verification'] = verification
|
|
910
|
-
else:
|
|
911
|
-
result['verification'] = None
|
|
912
1097
|
waves, wave_map = _waves(reader)
|
|
913
1098
|
result['waves'] = [waves.get(n, []) for n in range(1, max(waves, default=0) + 1)]
|
|
914
1099
|
branches = {row[0]: _nullable(row[2]) for _, row in reader.rows('results.tsv', 3)}
|
|
@@ -918,6 +1103,8 @@ def detail(path, source='plan') -> dict:
|
|
|
918
1103
|
for name in _RUN_CONTROLS:
|
|
919
1104
|
reader.stat(name)
|
|
920
1105
|
run_execs = _run_execs(reader)
|
|
1106
|
+
result['verification'] = _verification(reader, result['verification_status'],
|
|
1107
|
+
[item for item in run_execs if item['role'] == 'verification'])
|
|
921
1108
|
items, integrations = executions.review_dirs(reader)
|
|
922
1109
|
published = _published(reader)
|
|
923
1110
|
reviews = []
|
|
@@ -1549,6 +1736,112 @@ def fractal_node_diff(path, task_id, node, source='plan'):
|
|
|
1549
1736
|
return _fractal(path, task_id, fractal.node_diff, node)
|
|
1550
1737
|
|
|
1551
1738
|
|
|
1739
|
+
# --- verification logs by path ---------------------------------------------------------------
|
|
1740
|
+
|
|
1741
|
+
LANE_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}')
|
|
1742
|
+
LOG_TAIL = 65536 # the tail a log endpoint serves
|
|
1743
|
+
LOG_SCAN_MAX = 64 << 20 # what is read to number lines or reach a line range
|
|
1744
|
+
LOG_LINES = 400 # lines in one range
|
|
1745
|
+
LOG_LINE_MAX = 4000 # characters kept of one line in a range
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _log_name(lane, k, retry=False):
|
|
1749
|
+
if retry:
|
|
1750
|
+
return 'verification.retry.log'
|
|
1751
|
+
if lane is None:
|
|
1752
|
+
return 'verification.log'
|
|
1753
|
+
return f'verification.lane.{lane}.log' if k is None else f'verification.lane.{lane}.run-{k}.log'
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _open_log(path, name):
|
|
1757
|
+
"""The open log and its stat, or None: a missing file, a symlink, anything not regular."""
|
|
1758
|
+
try:
|
|
1759
|
+
fd = os.open(os.path.join(os.path.realpath(path), name), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
1760
|
+
except OSError:
|
|
1761
|
+
return None
|
|
1762
|
+
try:
|
|
1763
|
+
info = os.fstat(fd)
|
|
1764
|
+
if stat.S_ISREG(info.st_mode):
|
|
1765
|
+
return os.fdopen(fd, 'rb'), info
|
|
1766
|
+
except OSError:
|
|
1767
|
+
pass
|
|
1768
|
+
os.close(fd)
|
|
1769
|
+
return None
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _newlines(stream, end):
|
|
1773
|
+
"""How many newlines the first `end` bytes hold, or None past LOG_SCAN_MAX."""
|
|
1774
|
+
if end > LOG_SCAN_MAX:
|
|
1775
|
+
return None
|
|
1776
|
+
stream.seek(0)
|
|
1777
|
+
count = 0
|
|
1778
|
+
while end > 0:
|
|
1779
|
+
chunk = stream.read(min(1 << 20, end))
|
|
1780
|
+
if not chunk:
|
|
1781
|
+
break
|
|
1782
|
+
count += chunk.count(b'\n')
|
|
1783
|
+
end -= len(chunk)
|
|
1784
|
+
return count
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _log_tail(stream, info, name):
|
|
1788
|
+
offset = max(0, info.st_size - LOG_TAIL)
|
|
1789
|
+
stream.seek(offset)
|
|
1790
|
+
raw = stream.read(LOG_TAIL)
|
|
1791
|
+
if offset:
|
|
1792
|
+
# Start on a line: drop the line the cut went through, or at least a split character.
|
|
1793
|
+
cut = raw.find(b'\n')
|
|
1794
|
+
skip = cut + 1 if cut >= 0 else 0
|
|
1795
|
+
if cut < 0:
|
|
1796
|
+
while skip < min(3, len(raw)) and raw[skip] & 0xC0 == 0x80:
|
|
1797
|
+
skip += 1
|
|
1798
|
+
raw, offset = raw[skip:], offset + skip
|
|
1799
|
+
before = _newlines(stream, offset)
|
|
1800
|
+
return dict(name=name, size=info.st_size, mtime=float(info.st_mtime), tail=raw.decode('utf-8', 'replace'),
|
|
1801
|
+
tail_first_line=None if before is None else before + 1, truncated=offset > 0)
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
def _log_range(stream, info, name, first):
|
|
1805
|
+
"""LOG_LINES lines from line `first`: (lines, whether more follow or a line or the read was cut)."""
|
|
1806
|
+
lines, number, read, cut = [], 1, 0, False
|
|
1807
|
+
while read < LOG_SCAN_MAX:
|
|
1808
|
+
part = stream.readline(LOG_LINE_MAX * 4)
|
|
1809
|
+
if not part:
|
|
1810
|
+
return dict(name=name, size=info.st_size, first_line=first, lines=lines, truncated=cut)
|
|
1811
|
+
read += len(part)
|
|
1812
|
+
line, long = part, False
|
|
1813
|
+
while not part.endswith(b'\n') and len(part) == LOG_LINE_MAX * 4 and read < LOG_SCAN_MAX:
|
|
1814
|
+
part, long = stream.readline(LOG_LINE_MAX * 4), True
|
|
1815
|
+
read += len(part)
|
|
1816
|
+
if number >= first:
|
|
1817
|
+
if len(lines) == LOG_LINES:
|
|
1818
|
+
return dict(name=name, size=info.st_size, first_line=first, lines=lines, truncated=True)
|
|
1819
|
+
text = line.rstrip(b'\n').rstrip(b'\r').decode('utf-8', 'replace')
|
|
1820
|
+
if long or len(text) > LOG_LINE_MAX:
|
|
1821
|
+
text, cut = text[:LOG_LINE_MAX].rstrip('\ufffd') + '…', True
|
|
1822
|
+
lines.append(text)
|
|
1823
|
+
number += 1
|
|
1824
|
+
return dict(name=name, size=info.st_size, first_line=first, lines=lines, truncated=True)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def verification_log(path, lane=None, k=None, first=None, retry=False, source='plan'):
|
|
1828
|
+
"""The run's own verification log (with `retry`, its rerun's), a lane's or one run of a lane's:
|
|
1829
|
+
its last 64 KB from a line start, with that line's number, or LOG_LINES lines from line `first`.
|
|
1830
|
+
None (a 404) for a solo run, a name no lane can have, and a log that is missing, a symlink or
|
|
1831
|
+
not a regular file."""
|
|
1832
|
+
if _kind(path) != 'plan' or (lane is not None and not LANE_NAME.fullmatch(lane)):
|
|
1833
|
+
return None
|
|
1834
|
+
if (k is not None and (lane is None or k < 1)) or (first is not None and first < 1) or (retry and lane is not None):
|
|
1835
|
+
return None
|
|
1836
|
+
name = _log_name(lane, k, retry)
|
|
1837
|
+
opened = _open_log(path, name)
|
|
1838
|
+
if opened is None:
|
|
1839
|
+
return None
|
|
1840
|
+
stream, info = opened
|
|
1841
|
+
with stream:
|
|
1842
|
+
return _log_tail(stream, info, name) if first is None else _log_range(stream, info, name, first)
|
|
1843
|
+
|
|
1844
|
+
|
|
1552
1845
|
class Catalog:
|
|
1553
1846
|
def __init__(self, plans=(), roots=(), registry=True, registry_dir=None):
|
|
1554
1847
|
self.options = dict(plans=tuple(plans), roots=tuple(roots), registry=registry, registry_dir=registry_dir)
|
|
@@ -1617,6 +1910,9 @@ class Catalog:
|
|
|
1617
1910
|
def fractal_node_diff(self, key, task_id, node):
|
|
1618
1911
|
return self._find(key, fractal_node_diff, task_id, node)
|
|
1619
1912
|
|
|
1913
|
+
def verification_log(self, key, lane=None, k=None, first=None, retry=False):
|
|
1914
|
+
return self._find(key, verification_log, lane, k, first, retry)
|
|
1915
|
+
|
|
1620
1916
|
def models(self):
|
|
1621
1917
|
from forge_ui import models # it reads runs through this module
|
|
1622
1918
|
return models.collect(discover(**self.options))
|
package/ui/forge_ui/server.py
CHANGED
|
@@ -46,6 +46,8 @@ _FILES_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/files')
|
|
|
46
46
|
_FINDINGS_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/findings')
|
|
47
47
|
_EXPORT_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/export')
|
|
48
48
|
_FRACTAL_PATH = re.compile(_TASK_PATH.pattern + r'/fractal(?:/nodes/([A-Za-z0-9][A-Za-z0-9_-]{0,100})(/diff)?)?')
|
|
49
|
+
_VERIFY_LOG_PATH = re.compile(r'/api/runs/([0-9a-f]{12})/verification(?:/(retry)|/lanes/([^/]+)(?:/runs/([^/]+))?)?/log'
|
|
50
|
+
r'(?:/lines/([^/]+))?')
|
|
49
51
|
_STATIC_PATH = re.compile(r'/([A-Za-z0-9_.-]+\.(?:html|js|css|svg|woff2))')
|
|
50
52
|
_TYPES = {
|
|
51
53
|
'html': 'text/html; charset=utf-8',
|
|
@@ -88,6 +90,13 @@ def with_token(body, token):
|
|
|
88
90
|
return body[:end] + meta + body[end:]
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
def _count(segment):
|
|
94
|
+
"""None for a missing segment, its value for a positive decimal one, False for anything else."""
|
|
95
|
+
if segment is None:
|
|
96
|
+
return None
|
|
97
|
+
return int(segment) if re.fullmatch(r'[1-9][0-9]{0,9}', segment) else False
|
|
98
|
+
|
|
99
|
+
|
|
91
100
|
def _task_id(segment):
|
|
92
101
|
"""A task id from its URL segment, or None when it cannot name a task dir."""
|
|
93
102
|
try:
|
|
@@ -141,6 +150,13 @@ def api_get(catalog, path):
|
|
|
141
150
|
match = _FINDINGS_PATH.fullmatch(path)
|
|
142
151
|
if match:
|
|
143
152
|
return catalog.findings(match.group(1))
|
|
153
|
+
match = _VERIFY_LOG_PATH.fullmatch(path)
|
|
154
|
+
if match:
|
|
155
|
+
key, retry, lane, k, first = match.groups()
|
|
156
|
+
k, first = _count(k), _count(first)
|
|
157
|
+
if k is False or first is False:
|
|
158
|
+
return None
|
|
159
|
+
return catalog.verification_log(key, lane, k, first, retry=bool(retry))
|
|
144
160
|
match = _FRACTAL_PATH.fullmatch(path)
|
|
145
161
|
if match:
|
|
146
162
|
key, task_id, node, diff = match.group(1), _task_id(match.group(2)), match.group(3), match.group(4)
|
|
@@ -1062,7 +1062,8 @@ window.ForgeApp = (() => {
|
|
|
1062
1062
|
function busy() {
|
|
1063
1063
|
if (state.key) {
|
|
1064
1064
|
const run = state.cache.get(state.key)?.data;
|
|
1065
|
-
return !!run && (list(run.tasks).some(t => t.status === 'RUNNING') || (run.final_review && run.final_review.state === 'running')
|
|
1065
|
+
return !!run && (list(run.tasks).some(t => t.status === 'RUNNING') || (run.final_review && run.final_review.state === 'running')
|
|
1066
|
+
|| (run.verification && run.verification.state === 'running'));
|
|
1066
1067
|
}
|
|
1067
1068
|
return state.runs.some(r => r.counts && r.counts.RUNNING);
|
|
1068
1069
|
}
|