@giamme/forge 0.0.0-stage → 0.8.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 +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- package/ui/forge_ui/static/style.css +618 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
"""Read live forge plan and solo run directories without modifying them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import urllib.parse
|
|
13
|
+
|
|
14
|
+
from forge_ui import diffs, executions, findings, fractal
|
|
15
|
+
from forge_ui.findings import verdict as _verdict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Waves are returned as a dense, indexed list, including empty waves. Bound
|
|
19
|
+
# that expansion so a corrupt integer cannot allocate an arbitrary-sized list.
|
|
20
|
+
_MAX_WAVE = 10000
|
|
21
|
+
# RunDetail lists the newest final reviews only; older ones stay reachable by name.
|
|
22
|
+
_MAX_REVIEWS = 20
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_key(path) -> str:
|
|
26
|
+
return hashlib.sha1(os.fsencode(os.path.realpath(path))).hexdigest()[:12]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _kind(path):
|
|
30
|
+
try:
|
|
31
|
+
# Check the directory first: stale registry entries cost just one stat.
|
|
32
|
+
path = Path(path)
|
|
33
|
+
if not stat.S_ISDIR(path.stat().st_mode):
|
|
34
|
+
return None
|
|
35
|
+
if (path / 'tasks.tsv').is_file():
|
|
36
|
+
return 'plan'
|
|
37
|
+
# Both markers are required, so a notes directory that only has prompt.md is not a run.
|
|
38
|
+
if (path / 'prompt.md').is_file() and (path / 'repo').is_file():
|
|
39
|
+
return 'solo'
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
return None
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def discover(plans=(), roots=(), registry=True, registry_dir=None):
|
|
46
|
+
found, seen = [], set()
|
|
47
|
+
|
|
48
|
+
def add(path, source):
|
|
49
|
+
path = Path(path)
|
|
50
|
+
kind = _kind(path)
|
|
51
|
+
# `--root` stays plans-only: a tree of unrelated prompt.md files is not a run list.
|
|
52
|
+
if kind is None or (source == 'root' and kind != 'plan'):
|
|
53
|
+
return False
|
|
54
|
+
real = Path(os.path.realpath(path))
|
|
55
|
+
if real not in seen:
|
|
56
|
+
seen.add(real)
|
|
57
|
+
found.append((real, source))
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
def scan(path, depth=0):
|
|
61
|
+
if add(path, 'root') or depth == 2:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
children = sorted(path.iterdir())
|
|
65
|
+
except (OSError, ValueError):
|
|
66
|
+
return
|
|
67
|
+
for child in children:
|
|
68
|
+
try:
|
|
69
|
+
if not child.name.startswith('.') and not child.is_symlink() and child.is_dir():
|
|
70
|
+
scan(child, depth + 1)
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
for path in plans:
|
|
75
|
+
add(path, 'plan')
|
|
76
|
+
for path in roots:
|
|
77
|
+
scan(Path(path))
|
|
78
|
+
if registry:
|
|
79
|
+
base = Path(registry_dir) if registry_dir is not None else Path(
|
|
80
|
+
os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'forge' / 'runs'
|
|
81
|
+
try:
|
|
82
|
+
entries = sorted(base.glob('*.txt'))
|
|
83
|
+
except OSError:
|
|
84
|
+
entries = []
|
|
85
|
+
for entry in entries:
|
|
86
|
+
try:
|
|
87
|
+
with entry.open(encoding='utf-8', errors='replace') as stream:
|
|
88
|
+
for line in stream:
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if line and not line.startswith('#') and os.path.isabs(line):
|
|
91
|
+
add(line, 'registry')
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
continue
|
|
94
|
+
return found
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Read:
|
|
98
|
+
def __init__(self, path):
|
|
99
|
+
self.path = Path(os.path.realpath(path))
|
|
100
|
+
self.warnings = []
|
|
101
|
+
self.truncated = []
|
|
102
|
+
self.present_files = set()
|
|
103
|
+
self.updated = None
|
|
104
|
+
self.exists('')
|
|
105
|
+
|
|
106
|
+
def warn(self, name, reason):
|
|
107
|
+
self.warnings.append(f'{name}: {reason}')
|
|
108
|
+
|
|
109
|
+
def track(self, info):
|
|
110
|
+
value = float(info.st_mtime)
|
|
111
|
+
if self.updated is None or value > self.updated:
|
|
112
|
+
self.updated = value
|
|
113
|
+
|
|
114
|
+
def stat(self, name):
|
|
115
|
+
try:
|
|
116
|
+
info = (self.path / name).stat()
|
|
117
|
+
self.track(info)
|
|
118
|
+
return info
|
|
119
|
+
except FileNotFoundError:
|
|
120
|
+
if os.path.lexists(self.path / name):
|
|
121
|
+
self.warn(name, 'dangling symlink')
|
|
122
|
+
except (OSError, ValueError) as exc:
|
|
123
|
+
self.warn(name, f'cannot stat ({type(exc).__name__})')
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def exists(self, name):
|
|
127
|
+
return self.stat(name) is not None
|
|
128
|
+
|
|
129
|
+
def _decode(self, name, *chunks):
|
|
130
|
+
values, invalid = [], False
|
|
131
|
+
for raw in chunks:
|
|
132
|
+
value = raw.decode('utf-8', errors='replace')
|
|
133
|
+
if '�' in value:
|
|
134
|
+
try:
|
|
135
|
+
raw.decode('utf-8')
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
invalid = True
|
|
138
|
+
values.append(value)
|
|
139
|
+
if invalid or any('\x00' in value for value in values):
|
|
140
|
+
self.warn(name, 'invalid text encoding or NUL byte')
|
|
141
|
+
return values
|
|
142
|
+
|
|
143
|
+
def text(self, name, last_bytes=None):
|
|
144
|
+
try:
|
|
145
|
+
path = self.path / name
|
|
146
|
+
# Do not open special files (a FIFO could otherwise block forever).
|
|
147
|
+
info = path.stat()
|
|
148
|
+
self.present_files.add(name)
|
|
149
|
+
if not stat.S_ISREG(info.st_mode):
|
|
150
|
+
self.warn(name, 'not a regular file')
|
|
151
|
+
return None
|
|
152
|
+
with path.open('rb') as stream:
|
|
153
|
+
info = os.fstat(stream.fileno())
|
|
154
|
+
self.track(info)
|
|
155
|
+
if not stat.S_ISREG(info.st_mode):
|
|
156
|
+
self.warn(name, 'not a regular file')
|
|
157
|
+
return None
|
|
158
|
+
if last_bytes is not None:
|
|
159
|
+
stream.seek(max(0, info.st_size - last_bytes))
|
|
160
|
+
offset = stream.tell()
|
|
161
|
+
raw = stream.read(last_bytes if last_bytes is not None else -1)
|
|
162
|
+
if offset:
|
|
163
|
+
# A byte-limited tail may begin inside a UTF-8 character.
|
|
164
|
+
start = 0
|
|
165
|
+
while start < min(3, len(raw)) and raw[start] & 0xC0 == 0x80:
|
|
166
|
+
start += 1
|
|
167
|
+
raw = raw[start:]
|
|
168
|
+
return self._decode(name, raw)[0]
|
|
169
|
+
except FileNotFoundError:
|
|
170
|
+
if os.path.lexists(self.path / name):
|
|
171
|
+
self.warn(name, 'dangling symlink')
|
|
172
|
+
except (OSError, ValueError) as exc:
|
|
173
|
+
self.warn(name, f'cannot read ({type(exc).__name__})')
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def excerpt(self, name, head, tail, field):
|
|
177
|
+
"""The text of a file, or its first `head` and last `tail` characters when it is longer.
|
|
178
|
+
|
|
179
|
+
A big file costs two bounded reads, never a read of the whole file.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
path = self.path / name
|
|
183
|
+
info = path.stat()
|
|
184
|
+
self.present_files.add(name)
|
|
185
|
+
if not stat.S_ISREG(info.st_mode):
|
|
186
|
+
self.warn(name, 'not a regular file')
|
|
187
|
+
return None
|
|
188
|
+
with path.open('rb') as stream:
|
|
189
|
+
info = os.fstat(stream.fileno())
|
|
190
|
+
self.track(info)
|
|
191
|
+
if not stat.S_ISREG(info.st_mode):
|
|
192
|
+
self.warn(name, 'not a regular file')
|
|
193
|
+
return None
|
|
194
|
+
# A UTF-8 character is at most 4 bytes, so a file this small has at most head+tail characters.
|
|
195
|
+
if info.st_size <= 4 * (head + tail):
|
|
196
|
+
return self.clip(self._decode(name, stream.read(4 * (head + tail) + 4))[0], head, tail, field)
|
|
197
|
+
first = stream.read(4 * head)
|
|
198
|
+
stream.seek(info.st_size - 4 * tail)
|
|
199
|
+
last = stream.read(4 * tail)
|
|
200
|
+
except FileNotFoundError:
|
|
201
|
+
if os.path.lexists(self.path / name):
|
|
202
|
+
self.warn(name, 'dangling symlink')
|
|
203
|
+
return None
|
|
204
|
+
except (OSError, ValueError) as exc:
|
|
205
|
+
self.warn(name, f'cannot read ({type(exc).__name__})')
|
|
206
|
+
return None
|
|
207
|
+
# Drop a character the head read cut in half, and the tail's leading continuation bytes.
|
|
208
|
+
for back in range(1, min(4, len(first)) + 1):
|
|
209
|
+
byte = first[-back]
|
|
210
|
+
if byte & 0xC0 != 0x80:
|
|
211
|
+
size = 2 if byte >> 5 == 0b110 else 3 if byte >> 4 == 0b1110 else 4 if byte >> 3 == 0b11110 else 1
|
|
212
|
+
first = first[:-back] if size > back else first
|
|
213
|
+
break
|
|
214
|
+
start = 0
|
|
215
|
+
while start < min(3, len(last)) and last[start] & 0xC0 == 0x80:
|
|
216
|
+
start += 1
|
|
217
|
+
first, last = self._decode(name, first, last[start:])
|
|
218
|
+
self.truncated.append(field)
|
|
219
|
+
return first[:head] + '\n\u2026\n' + last[-tail:]
|
|
220
|
+
|
|
221
|
+
def clip(self, value, head, tail, field):
|
|
222
|
+
if value is not None and len(value) > head + tail:
|
|
223
|
+
self.truncated.append(field)
|
|
224
|
+
return value[:head] + '\n\u2026\n' + value[-tail:]
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
def word(self, name):
|
|
228
|
+
value = self.text(name)
|
|
229
|
+
return value.split()[0] if value and value.split() else None
|
|
230
|
+
|
|
231
|
+
def line(self, name):
|
|
232
|
+
value = self.text(name)
|
|
233
|
+
return (value.splitlines()[0].strip() or None) if value else None
|
|
234
|
+
|
|
235
|
+
def number(self, name, kind=float):
|
|
236
|
+
value = self.text(name)
|
|
237
|
+
if value is None:
|
|
238
|
+
return None
|
|
239
|
+
try:
|
|
240
|
+
result = kind(value.strip())
|
|
241
|
+
if not math.isfinite(result):
|
|
242
|
+
raise ValueError()
|
|
243
|
+
return result
|
|
244
|
+
except (ValueError, OverflowError):
|
|
245
|
+
self.warn(name, 'not a number' if kind is float else 'not an integer')
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def rows(self, name, columns):
|
|
249
|
+
value = self.text(name)
|
|
250
|
+
for n, line in enumerate((value or '').splitlines(), 1):
|
|
251
|
+
if not line.strip() or line.startswith('#'):
|
|
252
|
+
continue
|
|
253
|
+
parts = line.split('\t')
|
|
254
|
+
label = f'{name} line {n}'
|
|
255
|
+
if len(parts) < columns:
|
|
256
|
+
self.warn(label, f'expected at least {columns} columns')
|
|
257
|
+
else:
|
|
258
|
+
yield label, parts
|
|
259
|
+
|
|
260
|
+
def object(self, name, value=None):
|
|
261
|
+
if value is None:
|
|
262
|
+
value = self.text(name)
|
|
263
|
+
if value is None:
|
|
264
|
+
return None
|
|
265
|
+
try:
|
|
266
|
+
def invalid_constant(value):
|
|
267
|
+
raise ValueError('non-finite JSON constant')
|
|
268
|
+
|
|
269
|
+
obj = json.loads(value, parse_constant=invalid_constant)
|
|
270
|
+
if not isinstance(obj, dict):
|
|
271
|
+
raise ValueError()
|
|
272
|
+
# Huge JSON exponents can decode to infinity despite parse_constant.
|
|
273
|
+
json.dumps(obj, allow_nan=False)
|
|
274
|
+
return obj
|
|
275
|
+
except (ValueError, RecursionError, OverflowError):
|
|
276
|
+
self.warn(name, 'not a JSON object')
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
def cap(self, value, field, size=20000, tail=False):
|
|
280
|
+
if value is not None and len(value) > size:
|
|
281
|
+
self.truncated.append(field)
|
|
282
|
+
return '…' + value[-size:] if tail else value[:size] + '…'
|
|
283
|
+
return value
|
|
284
|
+
|
|
285
|
+
def children(self, name):
|
|
286
|
+
try:
|
|
287
|
+
return sorted((self.path / name).iterdir())
|
|
288
|
+
except FileNotFoundError:
|
|
289
|
+
return []
|
|
290
|
+
except (OSError, ValueError) as exc:
|
|
291
|
+
self.warn(name, f'cannot list ({type(exc).__name__})')
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _list(value):
|
|
298
|
+
return [part.strip() for part in value.split(',') if part.strip() not in ('', '-')]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _nullable(value):
|
|
302
|
+
return None if value in ('', '-', 'UNASSIGNED') else value
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _tasks(reader):
|
|
306
|
+
tasks = []
|
|
307
|
+
for label, row in reader.rows('tasks.tsv', 7):
|
|
308
|
+
ident, deps, difficulty, files, dwarf, qa = row[:6]
|
|
309
|
+
if not ident or ident in ('.', '..') or '/' in ident or '\x00' in ident or '�' in ident:
|
|
310
|
+
reader.warn(label, 'invalid task id')
|
|
311
|
+
continue
|
|
312
|
+
prefix = f'tasks/{ident}'
|
|
313
|
+
status = 'MERGED' if reader.exists(prefix + '/merged') else reader.word(prefix + '/status') or 'PENDING'
|
|
314
|
+
tasks.append(dict(id=ident, title='\t'.join(row[6:]), deps=_list(deps),
|
|
315
|
+
difficulty=difficulty, files=_list(files), dwarf=_nullable(dwarf),
|
|
316
|
+
qa=_nullable(qa), status=status))
|
|
317
|
+
return tasks
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
_TITLE_CAP = 160
|
|
321
|
+
# Files a run writes once, when it starts: their earliest time is when the run began. Stat'ed
|
|
322
|
+
# outside _Read.track, so they never move `updated`.
|
|
323
|
+
_PLAN_START_FILES = ('run_id', 'repo', 'request.md', 'goal.txt', 'tasks.tsv')
|
|
324
|
+
_SOLO_START_FILES = ('prompt.md', 'repo', 'request.md', 'goal.txt')
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _created(path, names):
|
|
328
|
+
times = []
|
|
329
|
+
for name in names:
|
|
330
|
+
try:
|
|
331
|
+
info = os.stat(path / name)
|
|
332
|
+
except (OSError, ValueError):
|
|
333
|
+
continue
|
|
334
|
+
value = getattr(info, 'st_birthtime', None) or info.st_mtime
|
|
335
|
+
if math.isfinite(value):
|
|
336
|
+
times.append(float(value))
|
|
337
|
+
return min(times) if times else None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _brief(title):
|
|
341
|
+
return title if len(title) <= _TITLE_CAP else title[:_TITLE_CAP] + '…'
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _summary(reader, source):
|
|
345
|
+
result = dict(key=run_key(reader.path), path=str(reader.path), source=source, kind='plan',
|
|
346
|
+
run_id=reader.line('run_id'), repo=reader.line('repo'),
|
|
347
|
+
goal=reader.line('goal.txt'), decompose_level=reader.word('decompose_level'),
|
|
348
|
+
verification_status=reader.word('verification.status'))
|
|
349
|
+
tasks = _tasks(reader)
|
|
350
|
+
counts = {}
|
|
351
|
+
for task in tasks:
|
|
352
|
+
counts[task['status']] = counts.get(task['status'], 0) + 1
|
|
353
|
+
final_review = reader.word('final-review.verdict')
|
|
354
|
+
dwarf_models = sorted({task['dwarf'] for task in tasks if task['dwarf']})
|
|
355
|
+
qa_models = sorted({task['qa'] for task in tasks if task['qa']})
|
|
356
|
+
result.update(updated=reader.updated, created=_created(reader.path, _PLAN_START_FILES),
|
|
357
|
+
task_count=len(tasks), counts=counts,
|
|
358
|
+
task_statuses=[dict(id=task['id'], status=task['status'], difficulty=task['difficulty'] or None,
|
|
359
|
+
title=_brief(task['title'])) for task in tasks],
|
|
360
|
+
models=sorted(set(dwarf_models) | set(qa_models)), dwarf_models=dwarf_models, qa_models=qa_models,
|
|
361
|
+
final_review=final_review)
|
|
362
|
+
return result, tasks
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _resolved_spec(value):
|
|
366
|
+
return executions.spec_of(executions.parse_resolved(value))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _role_spec(reader, role):
|
|
370
|
+
spec = reader.line(role + '.spec')
|
|
371
|
+
if spec:
|
|
372
|
+
return spec
|
|
373
|
+
return _resolved_spec(reader.text(role + '.resolved'))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _solo_status(reader):
|
|
377
|
+
return reader.word('status') or reader.word('verdict') or 'UNKNOWN'
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _solo_summary(reader, source):
|
|
381
|
+
status = _solo_status(reader)
|
|
382
|
+
# Stat the live logs without reading them, so `updated` moves while a model writes.
|
|
383
|
+
reader.exists('dwarf.log')
|
|
384
|
+
reader.exists('qa.log')
|
|
385
|
+
dwarf, qa = _role_spec(reader, 'dwarf'), _role_spec(reader, 'qa')
|
|
386
|
+
goal = reader.line('goal.txt')
|
|
387
|
+
return dict(key=run_key(reader.path), path=str(reader.path), source=source, kind='solo',
|
|
388
|
+
run_id=reader.path.name, repo=reader.line('repo'), goal=goal,
|
|
389
|
+
decompose_level=None, verification_status=None, updated=reader.updated,
|
|
390
|
+
created=_created(reader.path, _SOLO_START_FILES),
|
|
391
|
+
task_count=1, counts={status: 1},
|
|
392
|
+
task_statuses=[dict(id='solo', status=status, difficulty=None, title=_brief(goal or 'Solo run'))],
|
|
393
|
+
models=sorted({dwarf, qa} - {None}), dwarf_models=[dwarf] if dwarf else [],
|
|
394
|
+
qa_models=[qa] if qa else [], final_review=None)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def summarize(path, source='plan') -> dict:
|
|
398
|
+
reader = _Read(path)
|
|
399
|
+
if _kind(reader.path) == 'solo':
|
|
400
|
+
return _solo_summary(reader, source)
|
|
401
|
+
return _summary(reader, source)[0]
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _history(reader, name, escalation=False):
|
|
405
|
+
result = []
|
|
406
|
+
for label, row in reader.rows(name, 4 if escalation else 3):
|
|
407
|
+
try:
|
|
408
|
+
at, attempt = float(row[0]), int(row[1])
|
|
409
|
+
if not math.isfinite(at):
|
|
410
|
+
raise ValueError()
|
|
411
|
+
except (ValueError, OverflowError):
|
|
412
|
+
reader.warn(label, 'invalid epoch or attempt')
|
|
413
|
+
continue
|
|
414
|
+
entry = dict(at=at, attempt=attempt)
|
|
415
|
+
if escalation:
|
|
416
|
+
entry.update({'from': row[2], 'to': row[3]})
|
|
417
|
+
else:
|
|
418
|
+
entry.update(status=row[2], infra=_nullable(row[3]) if len(row) > 3 else None)
|
|
419
|
+
result.append(entry)
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _attempt_state(reader, prefix):
|
|
424
|
+
attempts = _history(reader, prefix + '/attempts.tsv')
|
|
425
|
+
attempt = reader.number(prefix + '/attempt', int)
|
|
426
|
+
if attempt is None:
|
|
427
|
+
attempt = max((item['attempt'] for item in attempts), default=None)
|
|
428
|
+
return attempts, attempt
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _gate(reader, name):
|
|
432
|
+
text = reader.text(name)
|
|
433
|
+
gate = findings.parse_gate(text)
|
|
434
|
+
if text is not None and gate is None:
|
|
435
|
+
reader.warn(name, 'not a gate line')
|
|
436
|
+
return gate
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _nonempty(reader, name):
|
|
440
|
+
info = reader.stat(name)
|
|
441
|
+
return info is not None and stat.S_ISREG(info.st_mode) and info.st_size > 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _task_execs(reader, task):
|
|
445
|
+
execs = executions.scan(reader, f'tasks/{task["id"]}/attempts', task['status'] == 'RUNNING', ('task', 'dwarf', 'qa'))
|
|
446
|
+
executions.nest(execs)
|
|
447
|
+
return executions.assign_attempts(execs, task['attempts'], task['attempt'])
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _solo_execs(reader, status, attempts):
|
|
451
|
+
execs = executions.scan(reader, 'attempts', status == 'RUNNING', ('solo', 'dwarf', 'qa'))
|
|
452
|
+
executions.nest(execs)
|
|
453
|
+
# A solo run appends its attempts.tsv row on exit, so the running attempt is the next one.
|
|
454
|
+
return executions.assign_attempts(execs, attempts, len(attempts) + 1)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _run_execs(reader):
|
|
458
|
+
return executions.scan(reader, 'attempts', True, ('parallel', 'verification'))
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _shadows(reader, ident, task_execs):
|
|
462
|
+
"""(entry, executions, dir) for every shadow/<id>/<attempt>-<k>/, sorted by attempt then k."""
|
|
463
|
+
result = []
|
|
464
|
+
for child in reader.children(f'shadow/{ident}'):
|
|
465
|
+
match = re.fullmatch(r'([0-9]+)-([0-9]+)', child.name)
|
|
466
|
+
if not match:
|
|
467
|
+
continue
|
|
468
|
+
try:
|
|
469
|
+
if not child.is_dir():
|
|
470
|
+
continue
|
|
471
|
+
attempt, k = map(int, match.groups())
|
|
472
|
+
except (OSError, ValueError):
|
|
473
|
+
continue
|
|
474
|
+
sub = f'shadow/{ident}/{child.name}'
|
|
475
|
+
obj = reader.object(sub + '/shadow.json') or {}
|
|
476
|
+
status = reader.word(sub + '/shadow.status')
|
|
477
|
+
# shadow.status is written when the shadow ends, whatever the outcome.
|
|
478
|
+
execs = executions.scan(reader, sub + '/attempts', status is None, ('qa',))
|
|
479
|
+
# A shadow may outlive the task execution that started it, so its state stays its own.
|
|
480
|
+
executions.nest(execs, task_execs, adopt_state=False)
|
|
481
|
+
for item in execs:
|
|
482
|
+
item['attempt'] = attempt
|
|
483
|
+
run = execs[-1] if execs else None
|
|
484
|
+
verdict, summary = executions.qa_result(reader, sub + '/qa.last')
|
|
485
|
+
primaries = [item for item in task_execs if item['role'] == 'qa' and not item['shadow'] and item['attempt'] == attempt]
|
|
486
|
+
primary = primaries[-1]['verdict'] if primaries else None
|
|
487
|
+
agreement = 'unknown'
|
|
488
|
+
if verdict in ('PASS', 'FAIL') and primary in ('PASS', 'FAIL'):
|
|
489
|
+
agreement = 'agree' if verdict == primary else 'disagree'
|
|
490
|
+
started = obj.get('started')
|
|
491
|
+
if started is None and run is not None:
|
|
492
|
+
started = run['started']
|
|
493
|
+
entry = dict(attempt=attempt, k=k, spec=obj.get('spec'), primary=obj.get('primary'), started=started,
|
|
494
|
+
status=status, verdict=verdict, reviewed=obj.get('reviewed'), pick=obj.get('pick'),
|
|
495
|
+
execution=run['id'] if run else None, findings=summary, primary_verdict=primary,
|
|
496
|
+
agreement=agreement, ended=run['ended'] if run else None,
|
|
497
|
+
duration_s=run['duration_s'] if run else None)
|
|
498
|
+
result.append((entry, execs, sub))
|
|
499
|
+
result.sort(key=lambda item: (item[0]['attempt'], item[0]['k']))
|
|
500
|
+
return result
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _task_detail(reader, task, run_id, waves, branches):
|
|
504
|
+
"""Fill in a plan task; returns every execution it read (its own and its shadows')."""
|
|
505
|
+
ident = task['id']
|
|
506
|
+
prefix = f'tasks/{ident}'
|
|
507
|
+
task.update(wave=waves.get(ident), branch=branches.get(ident))
|
|
508
|
+
if not task['branch'] and run_id:
|
|
509
|
+
try:
|
|
510
|
+
if (reader.path / prefix).is_dir():
|
|
511
|
+
task['branch'] = f'forge/{run_id}/{ident}'
|
|
512
|
+
except (OSError, ValueError):
|
|
513
|
+
pass
|
|
514
|
+
task['attempts'], task['attempt'] = _attempt_state(reader, prefix)
|
|
515
|
+
task['escalations'] = _history(reader, prefix + '/escalations.tsv', True)
|
|
516
|
+
qa = reader.text(prefix + '/qa.last')
|
|
517
|
+
task['verdict'] = _verdict(qa)
|
|
518
|
+
task['qa_tail'] = reader.cap(qa, prefix + '/qa_tail', 4000, True)
|
|
519
|
+
task['dwarf_tail'] = reader.cap(reader.text(prefix + '/dwarf.last'), prefix + '/dwarf_tail', 4000, True)
|
|
520
|
+
task['accepted_by'] = reader.text(prefix + '/accepted.by')
|
|
521
|
+
for field, filename in [('prompt', 'prompt.md'), ('approach', 'approach.md'), ('retry_findings', 'retry_findings.md')]:
|
|
522
|
+
task[field] = reader.cap(reader.text(prefix + '/' + filename), prefix + '/' + field)
|
|
523
|
+
task['route'] = []
|
|
524
|
+
for n, line in enumerate((reader.text(prefix + '/route.jsonl') or '').splitlines(), 1):
|
|
525
|
+
if line.strip():
|
|
526
|
+
obj = reader.object(f'{prefix}/route.jsonl line {n}', line)
|
|
527
|
+
if obj is not None:
|
|
528
|
+
task['route'].append(obj)
|
|
529
|
+
task['explored'] = reader.exists(prefix + '/route.explored') or any(
|
|
530
|
+
item.get('explored') is True for item in task['route'])
|
|
531
|
+
task['clef'] = {'first_pass': reader.object(prefix + '/clef.first_pass'), 'retry_pass': {}}
|
|
532
|
+
for child in reader.children(prefix):
|
|
533
|
+
if child.name.startswith('clef.retry_pass.'):
|
|
534
|
+
suffix = child.name[len('clef.retry_pass.'):]
|
|
535
|
+
if not re.fullmatch(r'[0-9]+', suffix):
|
|
536
|
+
reader.warn(prefix + '/' + child.name, 'invalid attempt suffix')
|
|
537
|
+
continue
|
|
538
|
+
obj = reader.object(prefix + '/' + child.name)
|
|
539
|
+
if obj is not None:
|
|
540
|
+
task['clef']['retry_pass'][suffix] = obj
|
|
541
|
+
execs = _task_execs(reader, task)
|
|
542
|
+
task['executions'] = execs
|
|
543
|
+
merged = reader.stat(prefix + '/merged') if task['status'] == 'MERGED' else None
|
|
544
|
+
task['merged_at'] = float(merged.st_mtime) if merged is not None else None
|
|
545
|
+
task['findings'] = executions.summary_of(qa)
|
|
546
|
+
task['gate'] = _gate(reader, prefix + '/qa.gate')
|
|
547
|
+
task['known_issues'] = _nonempty(reader, prefix + '/known_issues.md')
|
|
548
|
+
task['fail_first'] = _fail_first(reader, prefix + '/failfirst.json')
|
|
549
|
+
shadows = _shadows(reader, ident, execs)
|
|
550
|
+
task['shadow'] = [entry for entry, _, _ in shadows]
|
|
551
|
+
return execs + [item for _, items, _ in shadows for item in items]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
_FAIL_FIRST_RESULTS = ('proven', 'passes_without_change', 'inconclusive', 'error')
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _fail_first(reader, name):
|
|
558
|
+
"""failfirst.json (forge-fail-first.py, opt-in): did each new test fail without the change?"""
|
|
559
|
+
obj = reader.object(name)
|
|
560
|
+
if obj is None:
|
|
561
|
+
return None
|
|
562
|
+
status = obj.get('status')
|
|
563
|
+
if status not in ('ran', 'skipped', 'error'):
|
|
564
|
+
reader.warn(name, 'unknown fail-first status')
|
|
565
|
+
return None
|
|
566
|
+
tests = []
|
|
567
|
+
for item in obj.get('tests') if isinstance(obj.get('tests'), list) else []:
|
|
568
|
+
if isinstance(item, dict) and isinstance(item.get('path'), str) and item.get('result') in _FAIL_FIRST_RESULTS:
|
|
569
|
+
runs = {}
|
|
570
|
+
for side in ('with_change', 'without_change'):
|
|
571
|
+
run = item.get(side) if isinstance(item.get(side), dict) else {}
|
|
572
|
+
rc = run.get('rc')
|
|
573
|
+
runs[side] = dict(rc=rc if type(rc) is int else None,
|
|
574
|
+
tail=run['tail'][-2000:] if isinstance(run.get('tail'), str) else None)
|
|
575
|
+
tests.append(dict(path=item['path'], result=item['result'], **runs))
|
|
576
|
+
counts = dict.fromkeys(_FAIL_FIRST_RESULTS, 0)
|
|
577
|
+
for item in tests:
|
|
578
|
+
counts[item['result']] += 1
|
|
579
|
+
return dict(status=status, reason=obj.get('reason') if isinstance(obj.get('reason'), str) else None,
|
|
580
|
+
command=obj.get('command') if isinstance(obj.get('command'), str) else None,
|
|
581
|
+
tests=tests, counts=counts)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _published(reader):
|
|
585
|
+
"""realpath of the review the plan's `final-review` link names, if it stays inside the plan."""
|
|
586
|
+
link = reader.path / 'final-review'
|
|
587
|
+
try:
|
|
588
|
+
if not os.path.islink(link):
|
|
589
|
+
return None
|
|
590
|
+
real = os.path.realpath(link)
|
|
591
|
+
except (OSError, ValueError):
|
|
592
|
+
return None
|
|
593
|
+
return real if executions.inside(reader.path, real) else None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _final_review(reader, item, newest, published):
|
|
597
|
+
"""(FinalReviewSummary, its executions, review.meta) of one review dir."""
|
|
598
|
+
name, source, at, rel = item
|
|
599
|
+
verdict = reader.word(rel + '/verdict')
|
|
600
|
+
if verdict:
|
|
601
|
+
state = 'done'
|
|
602
|
+
elif reader.exists(rel + '/qa.infra'):
|
|
603
|
+
state = 'infra'
|
|
604
|
+
elif newest and reader.exists(rel + '/qa.input'):
|
|
605
|
+
state = 'running'
|
|
606
|
+
else:
|
|
607
|
+
state = 'orphaned'
|
|
608
|
+
meta = executions.parse_resolved(reader.text(rel + '/review.meta'))
|
|
609
|
+
execs = executions.scan(reader, rel + '/attempts', state == 'running', ('qa',))
|
|
610
|
+
run = execs[-1] if execs else None
|
|
611
|
+
threshold = meta.get('threshold') or None
|
|
612
|
+
try:
|
|
613
|
+
real = os.path.realpath(reader.path / rel)
|
|
614
|
+
except (OSError, ValueError):
|
|
615
|
+
real = None
|
|
616
|
+
summary = dict(name=name, source=source, at=at, published=published is not None and published == real,
|
|
617
|
+
verdict=verdict, state=state, spec=meta.get('spec') or None,
|
|
618
|
+
threshold=None if threshold == 'none' else threshold,
|
|
619
|
+
base=meta.get('base') or None, tip=meta.get('tip') or None,
|
|
620
|
+
gate=_gate(reader, rel + '/qa.gate'), findings=executions.qa_result(reader, rel + '/qa.last')[1],
|
|
621
|
+
execution=run['id'] if run else None, started=run['started'] if run else None,
|
|
622
|
+
ended=run['ended'] if run else None, duration_s=run['duration_s'] if run else None)
|
|
623
|
+
return summary, execs, meta
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _verification_runs(reader):
|
|
627
|
+
runs = []
|
|
628
|
+
for label, row in reader.rows('verification.runs.tsv', 6):
|
|
629
|
+
try:
|
|
630
|
+
n, duration = int(row[0]), float(row[4])
|
|
631
|
+
code = int(row[2]) if row[2].strip() else None
|
|
632
|
+
if row[3].strip() not in ('0', '1') or not math.isfinite(duration):
|
|
633
|
+
raise ValueError()
|
|
634
|
+
except (ValueError, OverflowError):
|
|
635
|
+
reader.warn(label, 'invalid verification run')
|
|
636
|
+
continue
|
|
637
|
+
runs.append(dict(n=n, kind=row[1], exit=code, tree_changed=row[3].strip() == '1',
|
|
638
|
+
duration_s=duration, log=row[5].strip() or None))
|
|
639
|
+
return runs
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _integration(reader, tasks, integrations, reviews, published):
|
|
643
|
+
base, accepted = reader.line('integration.base'), reader.line('accepted.integration')
|
|
644
|
+
merged = sorted((dict(id=task['id'], at=task['merged_at']) for task in tasks if task['merged_at'] is not None),
|
|
645
|
+
key=lambda item: (item['at'], item['id']))
|
|
646
|
+
recorded = {'integration.base', 'accepted.integration'} & reader.present_files
|
|
647
|
+
if not (recorded or merged or integrations):
|
|
648
|
+
return None
|
|
649
|
+
by_name = {summary['name']: summary for summary in reviews if summary['source'] == 'integrate'}
|
|
650
|
+
entries = []
|
|
651
|
+
for index, (name, at, item) in enumerate(integrations):
|
|
652
|
+
review = by_name.get(name)
|
|
653
|
+
if review is None and item is not None: # older than the reviews RunDetail lists
|
|
654
|
+
review = _final_review(reader, item, False, published)[0]
|
|
655
|
+
# Nothing records when an integration ended: its verification and its review did.
|
|
656
|
+
ends = [item['ended'] for item in executions.scan(reader, name + '/attempts', index == 0, ('verification',))]
|
|
657
|
+
if review is not None:
|
|
658
|
+
ends.append(review['ended'])
|
|
659
|
+
entries.append(dict(name=name, at=at, verification_status=reader.word(name + '/verification.status'),
|
|
660
|
+
final_review=name + '/final-review' if review is not None else None,
|
|
661
|
+
ended=max(ends) if ends and None not in ends else None))
|
|
662
|
+
return dict(base=base, accepted=accepted, merged=merged, integrations=entries)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _timeline(pool, runs, verifications):
|
|
666
|
+
starts = [item['started'] for item in pool if item['started'] is not None]
|
|
667
|
+
ends = [item['ended'] for item in pool if item['ended'] is not None]
|
|
668
|
+
running = any(item['state'] == 'running' for item in pool)
|
|
669
|
+
return dict(started=min(starts) if starts else None, ended=max(ends) if ends and not running else None,
|
|
670
|
+
runs=runs, verifications=verifications)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
_SOLO_SETTINGS = ('explore', 'shadow_rate', 'shadow_qa', 'timeout', 'max_parallel', 'qa_threshold',
|
|
674
|
+
'escalate_after', 'verify_retries', 'final_qa')
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _solo_detail(reader, source):
|
|
678
|
+
result = _solo_summary(reader, source)
|
|
679
|
+
status = next(iter(result['counts']))
|
|
680
|
+
result['request'] = reader.cap(reader.text('request.md'), 'request')
|
|
681
|
+
result['settings'] = dict.fromkeys(_SOLO_SETTINGS)
|
|
682
|
+
result['verification'] = None
|
|
683
|
+
result['waves'] = [['solo']]
|
|
684
|
+
attempts = _history(reader, 'attempts.tsv')
|
|
685
|
+
qa = reader.text('qa.last')
|
|
686
|
+
execs = _solo_execs(reader, status, attempts)
|
|
687
|
+
task = dict(id='solo', title=result['goal'] or 'Solo run', deps=[], difficulty=None, files=[],
|
|
688
|
+
dwarf=_role_spec(reader, 'dwarf'), qa=_role_spec(reader, 'qa'), wave=1, status=status,
|
|
689
|
+
branch=None, attempt=max((item['attempt'] for item in attempts), default=None),
|
|
690
|
+
attempts=attempts, escalations=[], verdict=_verdict(qa), accepted_by=None,
|
|
691
|
+
explored=False, route=[], clef={'first_pass': None, 'retry_pass': {}}, shadow=[],
|
|
692
|
+
prompt=reader.cap(reader.text('prompt.md'), 'tasks/solo/prompt'), approach=None,
|
|
693
|
+
retry_findings=None, qa_tail=reader.cap(qa, 'tasks/solo/qa_tail', 4000, True),
|
|
694
|
+
dwarf_tail=reader.cap(reader.text('dwarf.last'), 'tasks/solo/dwarf_tail', 4000, True),
|
|
695
|
+
executions=execs, merged_at=None, findings=executions.summary_of(qa),
|
|
696
|
+
gate=_gate(reader, 'qa.gate'), known_issues=_nonempty(reader, 'known_issues.md'),
|
|
697
|
+
fail_first=_fail_first(reader, 'failfirst.json'))
|
|
698
|
+
result.update(timeline=_timeline(execs, [], []), integration=None, final_review=None, final_reviews=[],
|
|
699
|
+
deferrals=[], findings_available=findings.available(), fractal=fractal.summary(reader))
|
|
700
|
+
result.update(tasks=[task], warnings=reader.warnings, truncated=reader.truncated, updated=reader.updated)
|
|
701
|
+
return result
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _waves(reader):
|
|
705
|
+
waves, wave_map = {}, {}
|
|
706
|
+
for label, row in reader.rows('waves.tsv', 2):
|
|
707
|
+
try:
|
|
708
|
+
wave = int(row[0])
|
|
709
|
+
if not 1 <= wave <= _MAX_WAVE:
|
|
710
|
+
raise ValueError()
|
|
711
|
+
except ValueError:
|
|
712
|
+
reader.warn(label, f'invalid wave number (expected 1..{_MAX_WAVE})')
|
|
713
|
+
continue
|
|
714
|
+
waves.setdefault(wave, []).append(row[1])
|
|
715
|
+
wave_map[row[1]] = wave
|
|
716
|
+
return waves, wave_map
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def detail(path, source='plan') -> dict:
|
|
720
|
+
reader = _Read(path)
|
|
721
|
+
if _kind(reader.path) == 'solo':
|
|
722
|
+
return _solo_detail(reader, source)
|
|
723
|
+
result, tasks = _summary(reader, source)
|
|
724
|
+
result['request'] = reader.cap(reader.text('request.md'), 'request')
|
|
725
|
+
result['settings'] = dict(explore=reader.number('explore'), shadow_rate=reader.number('shadow_rate'),
|
|
726
|
+
shadow_qa=_list(reader.text('shadow_qa') or ''), timeout=reader.number('timeout', int),
|
|
727
|
+
max_parallel=reader.number('max_parallel', int), qa_threshold=reader.line('qa_threshold'),
|
|
728
|
+
escalate_after=reader.number('escalate_after', int),
|
|
729
|
+
verify_retries=reader.number('verify_retries', int), final_qa=reader.line('final_qa'))
|
|
730
|
+
command = reader.text('verification.command')
|
|
731
|
+
verification = dict(status=result['verification_status'], command=command.strip() if command is not None else None,
|
|
732
|
+
log_tail=reader.cap(reader.text('verification.log', 65536), 'verification.log_tail', 4000, True))
|
|
733
|
+
if any('verification.' + suffix in reader.present_files for suffix in ('status', 'command', 'log')):
|
|
734
|
+
verification.update(runs=_verification_runs(reader), commit=reader.line('verification.commit'))
|
|
735
|
+
result['verification'] = verification
|
|
736
|
+
else:
|
|
737
|
+
result['verification'] = None
|
|
738
|
+
waves, wave_map = _waves(reader)
|
|
739
|
+
result['waves'] = [waves.get(n, []) for n in range(1, max(waves, default=0) + 1)]
|
|
740
|
+
branches = {row[0]: _nullable(row[2]) for _, row in reader.rows('results.tsv', 3)}
|
|
741
|
+
pool = []
|
|
742
|
+
for task in tasks:
|
|
743
|
+
pool += _task_detail(reader, task, result['run_id'], wave_map, branches)
|
|
744
|
+
run_execs = _run_execs(reader)
|
|
745
|
+
items, integrations = executions.review_dirs(reader)
|
|
746
|
+
published = _published(reader)
|
|
747
|
+
reviews = []
|
|
748
|
+
for index, item in enumerate(items[:_MAX_REVIEWS]):
|
|
749
|
+
summary, execs, _ = _final_review(reader, item, index == 0, published)
|
|
750
|
+
reviews.append(summary)
|
|
751
|
+
pool += execs
|
|
752
|
+
result['timeline'] = _timeline(pool + run_execs, [item for item in run_execs if item['role'] == 'parallel'],
|
|
753
|
+
[item for item in run_execs if item['role'] == 'verification'])
|
|
754
|
+
result['integration'] = _integration(reader, tasks, integrations, reviews, published)
|
|
755
|
+
result['final_review'] = reviews[0] if reviews else None
|
|
756
|
+
result['final_reviews'] = reviews
|
|
757
|
+
result['deferrals'] = [line.strip() for line in (reader.text('deferrals.txt') or '').splitlines() if line.strip()]
|
|
758
|
+
result['findings_available'] = findings.available()
|
|
759
|
+
result['fractal'] = fractal.summary(reader)
|
|
760
|
+
result.update(tasks=tasks, warnings=reader.warnings, truncated=reader.truncated, updated=reader.updated)
|
|
761
|
+
return result
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
# --- lazy details: one task, one execution, one final review ------------------------------
|
|
765
|
+
|
|
766
|
+
_TEXTS = (('prompt', 'prompt.md'), ('approach', 'approach.md'), ('retry_findings', 'retry_findings.md'),
|
|
767
|
+
('accepted_by', 'accepted.by'), ('known_issues', 'known_issues.md'), ('dwarf_input', 'dwarf.input'),
|
|
768
|
+
('qa_input', 'qa.input'))
|
|
769
|
+
# A solo run has no approach, retry findings or human acceptance (as in its RunDetail task).
|
|
770
|
+
_SOLO_TEXTS = ('prompt', 'known_issues', 'dwarf_input', 'qa_input')
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _plan_task(reader, tasks, task_id):
|
|
774
|
+
task = next((item for item in tasks if item['id'] == task_id), None)
|
|
775
|
+
if task is not None:
|
|
776
|
+
task['attempts'], task['attempt'] = _attempt_state(reader, f'tasks/{task_id}')
|
|
777
|
+
return task
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _review(kind, item, text):
|
|
781
|
+
return dict(kind=kind, execution=item['id'], attempt=item['attempt'], spec=(item['model'] or {}).get('spec'),
|
|
782
|
+
verdict=item['verdict'], state=item['state'], findings=findings.listing(text))
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def task_detail(path, task_id, source='plan'):
|
|
786
|
+
"""TaskDetail of one task (`solo` for a solo run), or None when there is no such task."""
|
|
787
|
+
reader = _Read(path)
|
|
788
|
+
kind = _kind(reader.path)
|
|
789
|
+
if kind == 'solo' and task_id == 'solo':
|
|
790
|
+
base, shadows, wanted = '', [], _SOLO_TEXTS
|
|
791
|
+
attempts = _history(reader, 'attempts.tsv')
|
|
792
|
+
execs = _solo_execs(reader, _solo_status(reader), attempts)
|
|
793
|
+
attempt, spec = max((item['attempt'] for item in attempts), default=None), _role_spec(reader, 'qa')
|
|
794
|
+
elif kind == 'plan':
|
|
795
|
+
task = _plan_task(reader, _tasks(reader), task_id)
|
|
796
|
+
if task is None:
|
|
797
|
+
return None
|
|
798
|
+
base, wanted = f'tasks/{task_id}/', [field for field, _ in _TEXTS]
|
|
799
|
+
execs = _task_execs(reader, task)
|
|
800
|
+
shadows = _shadows(reader, task_id, execs)
|
|
801
|
+
attempt, spec = task['attempt'], task['qa']
|
|
802
|
+
else:
|
|
803
|
+
return None
|
|
804
|
+
texts = {field: reader.excerpt(base + filename, 20000, 8000, field) if field in wanted else None
|
|
805
|
+
for field, filename in _TEXTS}
|
|
806
|
+
reviews, seen = [], set()
|
|
807
|
+
for item in execs:
|
|
808
|
+
if item['role'] == 'qa' and not item['shadow']:
|
|
809
|
+
text = reader.text(item.rel + '/qa.last') if item['state'] == 'done' else None
|
|
810
|
+
if text is not None:
|
|
811
|
+
seen.add(text)
|
|
812
|
+
reviews.append(_review('primary', item, text))
|
|
813
|
+
live = reader.text(base + 'qa.last')
|
|
814
|
+
# The reply in the task dir is normally the copy of the last review; only an unmatched one is listed.
|
|
815
|
+
if live is not None and live not in seen:
|
|
816
|
+
reviews.append(dict(kind='primary', execution=None, attempt=attempt, spec=spec, verdict=_verdict(live),
|
|
817
|
+
state='done', findings=findings.listing(live)))
|
|
818
|
+
for entry, items, sub in shadows:
|
|
819
|
+
run = items[-1] if items else None
|
|
820
|
+
reviews.append(dict(kind='shadow', execution=entry['execution'], attempt=entry['attempt'],
|
|
821
|
+
spec=entry['spec'] if isinstance(entry['spec'], str) else None, verdict=entry['verdict'],
|
|
822
|
+
state=run['state'] if run else None, findings=findings.listing(reader.text(sub + '/qa.last'))))
|
|
823
|
+
return dict(run=run_key(reader.path), id=task_id, texts=texts, gate=_gate(reader, base + 'qa.gate'),
|
|
824
|
+
reviews=reviews, truncated=reader.truncated, warnings=reader.warnings)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def execution_detail(path, name, source='plan'):
|
|
828
|
+
"""ExecutionDetail of one attempts/<role>-<uuid>/ dir of the run, or None."""
|
|
829
|
+
reader = _Read(path)
|
|
830
|
+
kind = _kind(reader.path)
|
|
831
|
+
if kind is None or not executions.NAME.fullmatch(name or ''):
|
|
832
|
+
return None
|
|
833
|
+
tasks = _tasks(reader) if kind == 'plan' else []
|
|
834
|
+
rel = executions.locate(reader.path, name, [task['id'] for task in tasks])
|
|
835
|
+
if rel is None:
|
|
836
|
+
return None
|
|
837
|
+
parts = rel.parts
|
|
838
|
+
if parts[0] == 'attempts' and kind == 'solo':
|
|
839
|
+
execs = _solo_execs(reader, _solo_status(reader), _history(reader, 'attempts.tsv'))
|
|
840
|
+
owner, owner_dir = dict(kind='task', id='solo'), ''
|
|
841
|
+
elif parts[0] == 'attempts':
|
|
842
|
+
execs, owner, owner_dir = _run_execs(reader), dict(kind='run', id=None), ''
|
|
843
|
+
elif parts[0] in ('tasks', 'shadow'):
|
|
844
|
+
task = _plan_task(reader, tasks, parts[1])
|
|
845
|
+
execs = _task_execs(reader, task)
|
|
846
|
+
owner, owner_dir = dict(kind='task', id=parts[1]), f'tasks/{parts[1]}'
|
|
847
|
+
if parts[0] == 'shadow':
|
|
848
|
+
owner_dir = f'shadow/{parts[1]}/{parts[2]}'
|
|
849
|
+
execs = next((items for _, items, sub in _shadows(reader, parts[1], execs) if sub == owner_dir), [])
|
|
850
|
+
owner = dict(kind='shadow', id=f'{parts[1]}/{parts[2]}')
|
|
851
|
+
else:
|
|
852
|
+
items = executions.review_dirs(reader)[0]
|
|
853
|
+
index = next((i for i, item in enumerate(items) if item[0] == parts[0]), None)
|
|
854
|
+
if index is None:
|
|
855
|
+
return None
|
|
856
|
+
execs = _final_review(reader, items[index], index == 0, _published(reader))[1]
|
|
857
|
+
owner, owner_dir = dict(kind='final_review', id=parts[0]), items[index][3]
|
|
858
|
+
item = next((candidate for candidate in execs if candidate['id'] == name), None)
|
|
859
|
+
if item is None:
|
|
860
|
+
return None
|
|
861
|
+
role, running = item['role'], item['state'] == 'running'
|
|
862
|
+
resolved = executions.resolved_of(reader, f'{item.rel}/{role}.resolved')
|
|
863
|
+
prompt = last = listed = None
|
|
864
|
+
if role in ('dwarf', 'qa'):
|
|
865
|
+
if resolved is None and running:
|
|
866
|
+
resolved = executions.parse_resolved(reader.text(posixpath.join(owner_dir, role + '.resolved'))) or None
|
|
867
|
+
prompt = reader.excerpt(f'{item.rel}/{role}.prompt', 40000, 20000, 'prompt')
|
|
868
|
+
# While it runs, the attempt dir has no copies yet: show what the dispatch is working from.
|
|
869
|
+
for suffix in ('delivered', 'prompt', 'input'):
|
|
870
|
+
if prompt is not None or not running:
|
|
871
|
+
break
|
|
872
|
+
prompt = reader.excerpt(posixpath.join(owner_dir, f'{role}.{suffix}'), 40000, 20000, 'prompt')
|
|
873
|
+
last = reader.cap(reader.text(f'{item.rel}/{role}.last', 4 * 20000 + 4), 'last', 20000, True)
|
|
874
|
+
if role == 'qa' and item['state'] == 'done':
|
|
875
|
+
listed = findings.listing(reader.text(f'{item.rel}/qa.last'))
|
|
876
|
+
result = dict(item)
|
|
877
|
+
result.update(resolved=dict(resolved) if resolved is not None else None,
|
|
878
|
+
dataset=dict(item.dataset) if item.dataset is not None else None, prompt=prompt, last=last,
|
|
879
|
+
findings=listed, owner=owner, truncated=reader.truncated, warnings=reader.warnings)
|
|
880
|
+
return result
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def final_review_detail(path, name, source='plan'):
|
|
884
|
+
"""FinalReviewDetail of final-review-<e>-<p> or integrate-<e>-<p>, or None."""
|
|
885
|
+
reader = _Read(path)
|
|
886
|
+
if _kind(reader.path) != 'plan':
|
|
887
|
+
return None
|
|
888
|
+
items = executions.review_dirs(reader)[0]
|
|
889
|
+
index = next((i for i, item in enumerate(items) if item[0] == name), None)
|
|
890
|
+
if index is None:
|
|
891
|
+
return None
|
|
892
|
+
summary, _, meta = _final_review(reader, items[index], index == 0, _published(reader))
|
|
893
|
+
rel = items[index][3]
|
|
894
|
+
qa_last = reader.text(rel + '/qa.last')
|
|
895
|
+
result = dict(summary)
|
|
896
|
+
result.update(meta=meta, tasks_summary=reader.excerpt(rel + '/tasks.summary', 20000, 8000, 'tasks_summary'),
|
|
897
|
+
qa_input=reader.excerpt(rel + '/qa.input', 40000, 20000, 'qa_input'),
|
|
898
|
+
qa_last=reader.clip(qa_last, 20000, 8000, 'qa_last'),
|
|
899
|
+
known_issues=reader.excerpt(rel + '/known_issues.md', 20000, 8000, 'known_issues'),
|
|
900
|
+
findings=findings.listing(qa_last), truncated=reader.truncated, warnings=reader.warnings)
|
|
901
|
+
return result
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
# --- diffs: what a task changed and what each review read, its findings pinned -------------
|
|
905
|
+
|
|
906
|
+
_PLAN_DIFFS = (('current', 'changes.diff'), ('prev', 'changes.prev.diff'), ('committed', 'committed.diff'))
|
|
907
|
+
_SOLO_DIFFS = (('current', 'changes.diff'), ('existing', 'existing.diff'))
|
|
908
|
+
_NOT_INLINED = 'not inlined (larger than 400 KB)'
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _url(key, *parts):
|
|
912
|
+
return '/api/runs/' + key + ''.join('/' + urllib.parse.quote(part, safe='') for part in parts)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _first(reader, rels):
|
|
916
|
+
return next((rel for rel in rels if os.path.lexists(reader.path / rel)), None)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _regular(reader, rel):
|
|
920
|
+
"""fstat of a regular file under the run dir, quietly; None otherwise."""
|
|
921
|
+
try:
|
|
922
|
+
real = os.path.realpath(reader.path / rel)
|
|
923
|
+
info = os.stat(real)
|
|
924
|
+
except (OSError, ValueError):
|
|
925
|
+
return None
|
|
926
|
+
return info if executions.inside(reader.path, real) and stat.S_ISREG(info.st_mode) else None
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _diff_review(kind, item, attempt, spec, verdict, last, prompts, execution=None):
|
|
930
|
+
return dict(kind=kind, item=item, execution=item['id'] if item is not None else execution, attempt=attempt,
|
|
931
|
+
spec=spec, verdict=verdict, last=last, prompts=prompts)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _prompts(item, owner_dir):
|
|
935
|
+
"""Where the diff a qa execution reviewed is: its prompt copy, made when it finishes; while it
|
|
936
|
+
runs, what its dispatch works from."""
|
|
937
|
+
prompts = [item.rel + '/qa.prompt']
|
|
938
|
+
if item['state'] == 'running':
|
|
939
|
+
prompts += [posixpath.join(owner_dir, 'qa.' + suffix) for suffix in ('delivered', 'prompt', 'input')]
|
|
940
|
+
return prompts
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _diff_task(reader, task_id):
|
|
944
|
+
"""What a task's diffs need: dir, diff files, attempt, executions and every review; None if no such task."""
|
|
945
|
+
kind = _kind(reader.path)
|
|
946
|
+
if kind == 'solo' and task_id == 'solo':
|
|
947
|
+
attempts = _history(reader, 'attempts.tsv')
|
|
948
|
+
execs = _solo_execs(reader, _solo_status(reader), attempts)
|
|
949
|
+
base, names, shadows = '', _SOLO_DIFFS, []
|
|
950
|
+
attempt, spec = max((item['attempt'] for item in attempts), default=None), _role_spec(reader, 'qa')
|
|
951
|
+
elif kind == 'plan':
|
|
952
|
+
task = _plan_task(reader, _tasks(reader), task_id)
|
|
953
|
+
if task is None:
|
|
954
|
+
return None
|
|
955
|
+
base, names = f'tasks/{task_id}', _PLAN_DIFFS
|
|
956
|
+
execs = _task_execs(reader, task)
|
|
957
|
+
shadows = _shadows(reader, task_id, execs)
|
|
958
|
+
attempt, spec = task['attempt'], task['qa']
|
|
959
|
+
else:
|
|
960
|
+
return None
|
|
961
|
+
reviews, copies = [], set()
|
|
962
|
+
for item in execs:
|
|
963
|
+
if item['role'] == 'qa' and not item['shadow']:
|
|
964
|
+
last = item.rel + '/qa.last' if item['state'] == 'done' else None
|
|
965
|
+
copies.add(reader.text(last) if last else None)
|
|
966
|
+
reviews.append(_diff_review('primary', item, item['attempt'], (item['model'] or {}).get('spec'),
|
|
967
|
+
item['verdict'], last, _prompts(item, base)))
|
|
968
|
+
live = posixpath.join(base, 'qa.last')
|
|
969
|
+
text = reader.text(live)
|
|
970
|
+
# As in TaskDetail: the task's own qa.last counts only when no execution's copy is identical.
|
|
971
|
+
if text is not None and text not in copies:
|
|
972
|
+
reviews.append(_diff_review('primary', None, attempt, spec, _verdict(text), live, []))
|
|
973
|
+
shadowed = []
|
|
974
|
+
for entry, items, sub in shadows:
|
|
975
|
+
run = items[-1] if items else None
|
|
976
|
+
shadowed += items
|
|
977
|
+
reviews.append(_diff_review('shadow', run, entry['attempt'], entry['spec'] if isinstance(entry['spec'], str)
|
|
978
|
+
else None, entry['verdict'], sub + '/qa.last',
|
|
979
|
+
([run.rel + '/qa.prompt'] if run else []) + [sub + '/qa.input']))
|
|
980
|
+
return dict(kind='task', id=task_id, dir=base, names=names, attempt=attempt, execs=execs + shadowed,
|
|
981
|
+
reviews=reviews)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _diff_final(reader, name):
|
|
985
|
+
items = executions.review_dirs(reader)[0]
|
|
986
|
+
index = next((i for i, item in enumerate(items) if item[0] == name), None)
|
|
987
|
+
if index is None:
|
|
988
|
+
return None
|
|
989
|
+
summary, execs, _ = _final_review(reader, items[index], index == 0, _published(reader))
|
|
990
|
+
reviews = [_diff_review('final', item, None, summary['spec'] or (item['model'] or {}).get('spec'),
|
|
991
|
+
item['verdict'], item.rel + '/qa.last' if item['state'] == 'done' else None,
|
|
992
|
+
[item.rel + '/qa.prompt'])
|
|
993
|
+
for item in execs]
|
|
994
|
+
return dict(kind='final_review', id=name, dir=items[index][3], items=items, summary=summary, execs=execs,
|
|
995
|
+
reviews=reviews)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _diff_sources(reader, key, ctx):
|
|
999
|
+
sources = []
|
|
1000
|
+
if ctx['kind'] == 'final_review':
|
|
1001
|
+
for name, _, _, rel in ctx['items'][:_MAX_REVIEWS]:
|
|
1002
|
+
info = _regular(reader, rel + '/combined.diff')
|
|
1003
|
+
if info is not None or name == ctx['id']:
|
|
1004
|
+
sources.append(dict(id=name, label='combined.diff' if info else 'reviewed diff', kind='final',
|
|
1005
|
+
verdict=reader.word(rel + '/verdict'), bytes=info.st_size if info else None,
|
|
1006
|
+
url=_url(key, 'final-reviews', name, 'diff')))
|
|
1007
|
+
return sources
|
|
1008
|
+
for which, name in ctx['names']:
|
|
1009
|
+
info = _regular(reader, posixpath.join(ctx['dir'], name))
|
|
1010
|
+
if info is not None:
|
|
1011
|
+
sources.append(dict(id=which, label=name, bytes=info.st_size,
|
|
1012
|
+
url=_url(key, 'tasks', ctx['id'], 'diff', *([which] if which != 'current' else []))))
|
|
1013
|
+
for review in ctx['reviews']:
|
|
1014
|
+
rel = _first(reader, review['prompts']) if review['item'] is not None else None
|
|
1015
|
+
size = diffs.fence_bytes(reader, rel) if rel else None
|
|
1016
|
+
if size is not None:
|
|
1017
|
+
label = 'reviewed diff' if review['kind'] == 'primary' else 'shadow review'
|
|
1018
|
+
if review['attempt'] is not None:
|
|
1019
|
+
label += f', attempt {review["attempt"]}'
|
|
1020
|
+
sources.append(dict(id=review['execution'], label=label, attempt=review['attempt'],
|
|
1021
|
+
verdict=review['verdict'], kind=review['kind'], bytes=size,
|
|
1022
|
+
url=_url(key, 'executions', review['execution'], 'diff')))
|
|
1023
|
+
return sources
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _diff_detail(reader, key, origin, sources, loaded, reason, reviews):
|
|
1027
|
+
files = loaded['files'] if loaded is not None else []
|
|
1028
|
+
found = []
|
|
1029
|
+
for review in reviews:
|
|
1030
|
+
text = reader.text(review['last']) if review['last'] else None
|
|
1031
|
+
for item in findings.listing(text) or []:
|
|
1032
|
+
found.append(dict(review={key: review[key] for key in ('kind', 'execution', 'attempt', 'spec')},
|
|
1033
|
+
severity=item['severity'], status=item['status'], location=item['location'],
|
|
1034
|
+
summary=item['summary'], text=item['text']))
|
|
1035
|
+
return dict(run=key, source=origin, sources=sources, available=loaded is not None,
|
|
1036
|
+
reason=None if loaded is not None else reason, bytes=loaded['bytes'] if loaded else None,
|
|
1037
|
+
truncated=bool(loaded and loaded['truncated']), files=files, findings=diffs.pin(found, files),
|
|
1038
|
+
warnings=reader.warnings, truncated_fields=reader.truncated)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _missing(reader, rel):
|
|
1042
|
+
return 'unreadable (see warnings)' if os.path.lexists(reader.path / rel) else 'no ' + posixpath.basename(rel)
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def task_diff(path, task_id, which='current', source='plan'):
|
|
1046
|
+
"""DiffDetail of a task's changes.diff (`current`), changes.prev.diff, committed.diff or, for a
|
|
1047
|
+
solo run, existing.diff; None when the task or that kind of diff does not exist."""
|
|
1048
|
+
reader = _Read(path)
|
|
1049
|
+
ctx = _diff_task(reader, task_id)
|
|
1050
|
+
names = dict(ctx['names']) if ctx is not None else {}
|
|
1051
|
+
if which not in names:
|
|
1052
|
+
return None
|
|
1053
|
+
rel = posixpath.join(ctx['dir'], names[which])
|
|
1054
|
+
loaded = diffs.load(reader, rel)
|
|
1055
|
+
latest = ctx['attempt']
|
|
1056
|
+
if latest is None:
|
|
1057
|
+
latest = max((review['attempt'] for review in ctx['reviews'] if review['attempt'] is not None), default=None)
|
|
1058
|
+
key = run_key(reader.path)
|
|
1059
|
+
origin = dict(kind='task', id=task_id, which=which, execution=None,
|
|
1060
|
+
attempt=latest if which in ('current', 'committed') else None, file=rel)
|
|
1061
|
+
return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded, _missing(reader, rel),
|
|
1062
|
+
[review for review in ctx['reviews'] if review['attempt'] == latest])
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def execution_diff(path, name, source='plan'):
|
|
1066
|
+
"""DiffDetail of the diff a qa execution reviewed (for a dwarf: its attempt's review), or None."""
|
|
1067
|
+
reader = _Read(path)
|
|
1068
|
+
kind = _kind(reader.path)
|
|
1069
|
+
if kind is None or not executions.NAME.fullmatch(name or '') or name.split('-', 1)[0] not in ('qa', 'dwarf'):
|
|
1070
|
+
return None
|
|
1071
|
+
rel = executions.locate(reader.path, name, [task['id'] for task in _tasks(reader)] if kind == 'plan' else [])
|
|
1072
|
+
if rel is None:
|
|
1073
|
+
return None
|
|
1074
|
+
parts = rel.parts
|
|
1075
|
+
if parts[0] == 'attempts':
|
|
1076
|
+
ctx = _diff_task(reader, 'solo') if kind == 'solo' else None
|
|
1077
|
+
elif parts[0] in ('tasks', 'shadow'):
|
|
1078
|
+
ctx = _diff_task(reader, parts[1])
|
|
1079
|
+
else:
|
|
1080
|
+
ctx = _diff_final(reader, parts[0])
|
|
1081
|
+
item = next((candidate for candidate in ctx['execs'] if candidate['id'] == name), None) if ctx else None
|
|
1082
|
+
if item is None:
|
|
1083
|
+
return None
|
|
1084
|
+
review = next((review for review in ctx['reviews'] if review['execution'] == name), None)
|
|
1085
|
+
if review is None and item['role'] == 'qa': # an older run of a shadow dir
|
|
1086
|
+
review = _diff_review('shadow', item, item['attempt'], (item['model'] or {}).get('spec'), item['verdict'],
|
|
1087
|
+
item.rel + '/qa.last' if item['state'] == 'done' else None, [item.rel + '/qa.prompt'])
|
|
1088
|
+
elif review is None:
|
|
1089
|
+
# A dwarf's diff is the one its attempt's review read.
|
|
1090
|
+
def same(other):
|
|
1091
|
+
if item['parent']:
|
|
1092
|
+
return other['parent'] == item['parent']
|
|
1093
|
+
return item['attempt'] is not None and other['attempt'] == item['attempt']
|
|
1094
|
+
matches = [other for other in ctx['reviews']
|
|
1095
|
+
if other['kind'] == 'primary' and other['item'] and same(other['item'])]
|
|
1096
|
+
review = matches[-1] if matches else None
|
|
1097
|
+
prompt = _first(reader, review['prompts']) if review else None
|
|
1098
|
+
loaded = diffs.load(reader, prompt, fence=True) if prompt else None
|
|
1099
|
+
if review is None:
|
|
1100
|
+
reason = 'no review of this attempt'
|
|
1101
|
+
elif prompt is None:
|
|
1102
|
+
reason = 'no review prompt'
|
|
1103
|
+
elif _regular(reader, prompt) is None:
|
|
1104
|
+
reason = 'unreadable (see warnings)'
|
|
1105
|
+
else:
|
|
1106
|
+
reason = _NOT_INLINED if ctx['kind'] == 'final_review' else 'no diff in the review prompt'
|
|
1107
|
+
reviews = [review] if review else []
|
|
1108
|
+
if review and review['kind'] == 'primary' and review['attempt'] is not None:
|
|
1109
|
+
reviews += [other for other in ctx['reviews']
|
|
1110
|
+
if other['kind'] == 'shadow' and other['attempt'] == review['attempt']]
|
|
1111
|
+
key = run_key(reader.path)
|
|
1112
|
+
origin = dict(kind='execution', id=name, which='reviewed', execution=review['execution'] if review else None,
|
|
1113
|
+
attempt=review['attempt'] if review else item['attempt'], file=prompt)
|
|
1114
|
+
return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded, reason, reviews)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def final_review_diff(path, name, source='plan'):
|
|
1118
|
+
"""DiffDetail of a final review: combined.diff, else the ```diff fence of its prompt, or None."""
|
|
1119
|
+
reader = _Read(path)
|
|
1120
|
+
if _kind(reader.path) != 'plan':
|
|
1121
|
+
return None
|
|
1122
|
+
ctx = _diff_final(reader, name)
|
|
1123
|
+
if ctx is None:
|
|
1124
|
+
return None
|
|
1125
|
+
base, summary = ctx['dir'], ctx['summary']
|
|
1126
|
+
candidates = [(base + '/combined.diff', False)]
|
|
1127
|
+
candidates += [(review['prompts'][0], True) for review in reversed(ctx['reviews'])] + [(base + '/qa.input', True)]
|
|
1128
|
+
loaded = rel = None
|
|
1129
|
+
for rel, fence in candidates:
|
|
1130
|
+
if os.path.lexists(reader.path / rel):
|
|
1131
|
+
loaded = diffs.load(reader, rel, fence)
|
|
1132
|
+
if loaded is not None:
|
|
1133
|
+
break
|
|
1134
|
+
prompted = any(os.path.lexists(reader.path / candidate) for candidate, fence in candidates if fence)
|
|
1135
|
+
key = run_key(reader.path)
|
|
1136
|
+
origin = dict(kind='final_review', id=name, which='reviewed', execution=summary['execution'], attempt=None,
|
|
1137
|
+
file=rel if loaded is not None else None)
|
|
1138
|
+
review = _diff_review('final', None, None, summary['spec'], summary['verdict'], base + '/qa.last', [],
|
|
1139
|
+
summary['execution'])
|
|
1140
|
+
return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded,
|
|
1141
|
+
_NOT_INLINED if prompted else 'no review prompt yet', [review])
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# --- files: who planned, changed, drifted into or conflicted on each file --------------------
|
|
1145
|
+
|
|
1146
|
+
_MAX_FILES, _MAX_TASKS, _MAX_OVERLAPS = 2000, 300, 5000
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _lines(reader, name):
|
|
1150
|
+
return [line.strip() for line in (reader.text(name) or '').splitlines() if line.strip()]
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _scope(path):
|
|
1154
|
+
return posixpath.normpath(path.replace('\\', '/'))
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _covers(scope, path):
|
|
1158
|
+
return path == scope or path.startswith(scope + '/')
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _overlap(left, right):
|
|
1162
|
+
"""forge-schedule.py's overlap(): equal, one a dir prefix of the other, or either `.`."""
|
|
1163
|
+
return any(x == '.' or y == '.' or _covers(x, y) or _covers(y, x) for x in left for y in right)
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def files_detail(path, source='plan'):
|
|
1167
|
+
"""Files of a run: which tasks planned, touched, drifted into or conflicted on each one."""
|
|
1168
|
+
reader = _Read(path)
|
|
1169
|
+
kind = _kind(reader.path)
|
|
1170
|
+
if kind is None:
|
|
1171
|
+
return None
|
|
1172
|
+
truncated, rows, wave_map, deferrals = False, [], {}, []
|
|
1173
|
+
if kind == 'solo':
|
|
1174
|
+
attempts = _history(reader, 'attempts.tsv')
|
|
1175
|
+
status = _solo_status(reader)
|
|
1176
|
+
rows.append((dict(id='solo', status=status, files=[]), '', _solo_execs(reader, status, attempts)))
|
|
1177
|
+
wave_map = {'solo': 1}
|
|
1178
|
+
else:
|
|
1179
|
+
tasks = _tasks(reader)
|
|
1180
|
+
if len(tasks) > _MAX_TASKS:
|
|
1181
|
+
tasks, truncated = tasks[:_MAX_TASKS], True
|
|
1182
|
+
wave_map = _waves(reader)[1]
|
|
1183
|
+
for task in tasks:
|
|
1184
|
+
task['attempts'], task['attempt'] = _attempt_state(reader, f'tasks/{task["id"]}')
|
|
1185
|
+
rows.append((task, f'tasks/{task["id"]}', _task_execs(reader, task)))
|
|
1186
|
+
deferrals = _lines(reader, 'deferrals.txt')
|
|
1187
|
+
entries, raw = [], {}
|
|
1188
|
+
for task, base, execs in rows:
|
|
1189
|
+
attempts = []
|
|
1190
|
+
for item in execs:
|
|
1191
|
+
if item['role'] == 'qa' and not item['shadow']:
|
|
1192
|
+
prompt = _first(reader, _prompts(item, base))
|
|
1193
|
+
paths = diffs.touched_of(reader, prompt, fence=True) if prompt else None
|
|
1194
|
+
if paths is not None:
|
|
1195
|
+
attempts.append(dict(attempt=item['attempt'], execution=item['id'], files=paths))
|
|
1196
|
+
planned = [_scope(entry) for entry in task['files']]
|
|
1197
|
+
raw[task['id']] = task['files']
|
|
1198
|
+
entry = dict(id=task['id'], wave=wave_map.get(task['id']), status=task['status'], planned=planned,
|
|
1199
|
+
planned_all='.' in planned, attempts=attempts,
|
|
1200
|
+
current=diffs.touched_of(reader, posixpath.join(base, 'changes.diff')) or [],
|
|
1201
|
+
drift=_lines(reader, base + '/drift.txt') if base else [],
|
|
1202
|
+
conflicts=_lines(reader, base + '/conflict.files') if base else [])
|
|
1203
|
+
if kind == 'solo':
|
|
1204
|
+
entry['existing'] = diffs.touched_of(reader, 'existing.diff') or []
|
|
1205
|
+
entries.append(entry)
|
|
1206
|
+
files, touched = {}, {}
|
|
1207
|
+
|
|
1208
|
+
def add(path):
|
|
1209
|
+
if path not in files:
|
|
1210
|
+
if len(files) >= _MAX_FILES:
|
|
1211
|
+
return None
|
|
1212
|
+
files[path] = dict(path=path, dir=posixpath.dirname(path), planned_by=[], touched_by=[], drift_by=[],
|
|
1213
|
+
conflict_by=[])
|
|
1214
|
+
return files[path]
|
|
1215
|
+
for entry in entries:
|
|
1216
|
+
touched[entry['id']] = set(entry['current']).union(*(item['files'] for item in entry['attempts']))
|
|
1217
|
+
for field, paths in (('touched_by', sorted(touched[entry['id']])), ('drift_by', entry['drift']),
|
|
1218
|
+
('conflict_by', entry['conflicts'])):
|
|
1219
|
+
for name in paths:
|
|
1220
|
+
item = add(name)
|
|
1221
|
+
truncated = truncated or item is None
|
|
1222
|
+
if item is not None and entry['id'] not in item[field]:
|
|
1223
|
+
item[field].append(entry['id'])
|
|
1224
|
+
dirs = {}
|
|
1225
|
+
for entry in entries:
|
|
1226
|
+
for scope, given in zip(entry['planned'], raw[entry['id']]):
|
|
1227
|
+
if scope == '.':
|
|
1228
|
+
continue
|
|
1229
|
+
# A planned entry is a dir when it says so or holds a file the run changed.
|
|
1230
|
+
if given.endswith('/') or (scope not in files and any(_covers(scope, name) for name in files)):
|
|
1231
|
+
planners = dirs.setdefault(scope + '/', [])
|
|
1232
|
+
if entry['id'] not in planners:
|
|
1233
|
+
planners.append(entry['id'])
|
|
1234
|
+
elif add(scope) is None:
|
|
1235
|
+
truncated = True
|
|
1236
|
+
for item in files.values():
|
|
1237
|
+
item['planned_by'] = [entry['id'] for entry in entries
|
|
1238
|
+
if any(scope != '.' and _covers(scope, item['path']) for scope in entry['planned'])]
|
|
1239
|
+
overlaps = []
|
|
1240
|
+
for i, left in enumerate(entries):
|
|
1241
|
+
for right in entries[i + 1:]:
|
|
1242
|
+
planned = _overlap(left['planned'], right['planned'])
|
|
1243
|
+
shared = sorted(touched[left['id']] & touched[right['id']])
|
|
1244
|
+
if planned or shared:
|
|
1245
|
+
if len(overlaps) >= _MAX_OVERLAPS:
|
|
1246
|
+
truncated = True
|
|
1247
|
+
break
|
|
1248
|
+
overlaps.append(dict(a=left['id'], b=right['id'], planned=planned, touched=shared,
|
|
1249
|
+
same_wave=left['wave'] is not None and left['wave'] == right['wave']))
|
|
1250
|
+
return dict(run=run_key(reader.path), files=[files[name] for name in sorted(files)],
|
|
1251
|
+
planned_dirs=[dict(path=name, planned_by=dirs[name]) for name in sorted(dirs)], tasks=entries,
|
|
1252
|
+
overlaps=overlaps, deferrals=deferrals, truncated=truncated, warnings=reader.warnings)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
# --- Fractal: the child tasks inside a task ---------------------------------------------------
|
|
1256
|
+
|
|
1257
|
+
def _fractal(path, task_id, read, *args):
|
|
1258
|
+
reader = _Read(path)
|
|
1259
|
+
kind = _kind(reader.path)
|
|
1260
|
+
if kind == 'solo' and task_id == 'solo':
|
|
1261
|
+
target = str(reader.path)
|
|
1262
|
+
elif kind == 'plan' and any(task['id'] == task_id for task in _tasks(reader)):
|
|
1263
|
+
# forge hands Fractal the task dir as --run-dir, resolved: its realpath names the Fractal task.
|
|
1264
|
+
target = os.path.realpath(reader.path / 'tasks' / task_id)
|
|
1265
|
+
else:
|
|
1266
|
+
return None
|
|
1267
|
+
return read(reader, run_key(reader.path), task_id, target, *args)
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def fractal_detail(path, task_id, source='plan'):
|
|
1271
|
+
return _fractal(path, task_id, fractal.task)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def fractal_node(path, task_id, node, source='plan'):
|
|
1275
|
+
return _fractal(path, task_id, fractal.node, node)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def fractal_node_diff(path, task_id, node, source='plan'):
|
|
1279
|
+
return _fractal(path, task_id, fractal.node_diff, node)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
class Catalog:
|
|
1283
|
+
def __init__(self, plans=(), roots=(), registry=True, registry_dir=None):
|
|
1284
|
+
self.options = dict(plans=tuple(plans), roots=tuple(roots), registry=registry, registry_dir=registry_dir)
|
|
1285
|
+
|
|
1286
|
+
def runs(self):
|
|
1287
|
+
runs = []
|
|
1288
|
+
for path, source in discover(**self.options):
|
|
1289
|
+
result = summarize(path, source)
|
|
1290
|
+
if _kind(path):
|
|
1291
|
+
runs.append(result)
|
|
1292
|
+
return sorted(runs, key=lambda item: item['updated'] if item['updated'] is not None else float('-inf'), reverse=True)
|
|
1293
|
+
|
|
1294
|
+
def _find(self, key, read, *args):
|
|
1295
|
+
for path, source in discover(**self.options):
|
|
1296
|
+
if run_key(path) == key:
|
|
1297
|
+
result = read(path, *args, source=source)
|
|
1298
|
+
return result if _kind(path) else None
|
|
1299
|
+
return None
|
|
1300
|
+
|
|
1301
|
+
def get(self, key):
|
|
1302
|
+
return self._find(key, detail)
|
|
1303
|
+
|
|
1304
|
+
def task(self, key, task_id):
|
|
1305
|
+
return self._find(key, task_detail, task_id)
|
|
1306
|
+
|
|
1307
|
+
def execution(self, key, name):
|
|
1308
|
+
return self._find(key, execution_detail, name)
|
|
1309
|
+
|
|
1310
|
+
def final_review(self, key, name):
|
|
1311
|
+
return self._find(key, final_review_detail, name)
|
|
1312
|
+
|
|
1313
|
+
def diff(self, key, task_id, which='current'):
|
|
1314
|
+
return self._find(key, task_diff, task_id, which)
|
|
1315
|
+
|
|
1316
|
+
def execution_diff(self, key, name):
|
|
1317
|
+
return self._find(key, execution_diff, name)
|
|
1318
|
+
|
|
1319
|
+
def final_review_diff(self, key, name):
|
|
1320
|
+
return self._find(key, final_review_diff, name)
|
|
1321
|
+
|
|
1322
|
+
def files(self, key):
|
|
1323
|
+
return self._find(key, files_detail)
|
|
1324
|
+
|
|
1325
|
+
def fractal(self, key, task_id):
|
|
1326
|
+
return self._find(key, fractal_detail, task_id)
|
|
1327
|
+
|
|
1328
|
+
def fractal_node(self, key, task_id, node):
|
|
1329
|
+
return self._find(key, fractal_node, task_id, node)
|
|
1330
|
+
|
|
1331
|
+
def fractal_node_diff(self, key, task_id, node):
|
|
1332
|
+
return self._find(key, fractal_node_diff, task_id, node)
|
|
1333
|
+
|
|
1334
|
+
def models(self):
|
|
1335
|
+
from forge_ui import models # it reads runs through this module
|
|
1336
|
+
return models.collect(discover(**self.options))
|