@giamme/forge 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. package/ui/forge_ui/static/verifyview.js +324 -0
@@ -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
- if re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
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}
@@ -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
- _RUN_CONTROLS = ('control.log', 'retry_budget', 'infra_budget')
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
- if row[3].strip() not in ('0', '1') or not math.isfinite(duration):
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))
@@ -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
  }