@giamme/forge 0.0.0-stage → 0.9.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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,1424 @@
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, transcripts
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(reader, name):
828
+ """(Execution, owner, owner dir) of the execution `name` of the run, or None."""
829
+ kind = _kind(reader.path)
830
+ if kind is None or not executions.NAME.fullmatch(name or ''):
831
+ return None
832
+ tasks = _tasks(reader) if kind == 'plan' else []
833
+ rel = executions.locate(reader.path, name, [task['id'] for task in tasks])
834
+ if rel is None:
835
+ return None
836
+ parts = rel.parts
837
+ if parts[0] == 'attempts' and kind == 'solo':
838
+ execs = _solo_execs(reader, _solo_status(reader), _history(reader, 'attempts.tsv'))
839
+ owner, owner_dir = dict(kind='task', id='solo'), ''
840
+ elif parts[0] == 'attempts':
841
+ execs, owner, owner_dir = _run_execs(reader), dict(kind='run', id=None), ''
842
+ elif parts[0] in ('tasks', 'shadow'):
843
+ task = _plan_task(reader, tasks, parts[1])
844
+ execs = _task_execs(reader, task)
845
+ owner, owner_dir = dict(kind='task', id=parts[1]), f'tasks/{parts[1]}'
846
+ if parts[0] == 'shadow':
847
+ owner_dir = f'shadow/{parts[1]}/{parts[2]}'
848
+ execs = next((items for _, items, sub in _shadows(reader, parts[1], execs) if sub == owner_dir), [])
849
+ owner = dict(kind='shadow', id=f'{parts[1]}/{parts[2]}')
850
+ else:
851
+ items = executions.review_dirs(reader)[0]
852
+ index = next((i for i, item in enumerate(items) if item[0] == parts[0]), None)
853
+ if index is None:
854
+ return None
855
+ execs = _final_review(reader, items[index], index == 0, _published(reader))[1]
856
+ owner, owner_dir = dict(kind='final_review', id=parts[0]), items[index][3]
857
+ item = next((candidate for candidate in execs if candidate['id'] == name), None)
858
+ return None if item is None else (item, owner, owner_dir)
859
+
860
+
861
+ def execution_detail(path, name, source='plan'):
862
+ """ExecutionDetail of one attempts/<role>-<uuid>/ dir of the run, or None."""
863
+ reader = _Read(path)
864
+ found = _execution(reader, name)
865
+ if found is None:
866
+ return None
867
+ item, owner, owner_dir = found
868
+ role, running = item['role'], item['state'] == 'running'
869
+ resolved = executions.resolved_of(reader, f'{item.rel}/{role}.resolved')
870
+ prompt = last = listed = None
871
+ if role in ('dwarf', 'qa'):
872
+ if resolved is None and running:
873
+ resolved = executions.parse_resolved(reader.text(posixpath.join(owner_dir, role + '.resolved'))) or None
874
+ prompt = reader.excerpt(f'{item.rel}/{role}.prompt', 40000, 20000, 'prompt')
875
+ # While it runs, the attempt dir has no copies yet: show what the dispatch is working from.
876
+ for suffix in ('delivered', 'prompt', 'input'):
877
+ if prompt is not None or not running:
878
+ break
879
+ prompt = reader.excerpt(posixpath.join(owner_dir, f'{role}.{suffix}'), 40000, 20000, 'prompt')
880
+ last = reader.cap(reader.text(f'{item.rel}/{role}.last', 4 * 20000 + 4), 'last', 20000, True)
881
+ if role == 'qa' and item['state'] == 'done':
882
+ listed = findings.listing(reader.text(f'{item.rel}/qa.last'))
883
+ result = dict(item)
884
+ result.update(resolved=dict(resolved) if resolved is not None else None,
885
+ dataset=dict(item.dataset) if item.dataset is not None else None, prompt=prompt, last=last,
886
+ findings=listed, owner=owner, truncated=reader.truncated, warnings=reader.warnings)
887
+ return result
888
+
889
+
890
+ def execution_transcript(path, name, source='plan'):
891
+ """ExecutionTranscript of a dwarf or qa execution: its agent's conversation, read from its `.log`; None
892
+ when there is no such execution or it is of another role."""
893
+ reader = _Read(path)
894
+ found = _execution(reader, name)
895
+ if found is None or found[0]['role'] not in ('dwarf', 'qa'):
896
+ return None
897
+ item, owner, owner_dir = found
898
+ role, running = item['role'], item['state'] == 'running'
899
+ # A finished execution's log is its copy; the owner dir's is the one being written, or the next one's.
900
+ rel = posixpath.join(owner_dir, role + '.log') if running else f'{item.rel}/{role}.log'
901
+ log = None
902
+ try:
903
+ real = Path(os.path.realpath(reader.path / rel))
904
+ if executions.inside(reader.path, str(real)) and stat.S_ISREG(real.stat().st_mode):
905
+ log = real
906
+ elif real.exists():
907
+ reader.warn(rel, 'outside the run dir or not a regular file')
908
+ except (OSError, ValueError):
909
+ pass
910
+ resolved = executions.resolved_of(reader, f'{item.rel}/{role}.resolved')
911
+ if resolved is None and running:
912
+ resolved = executions.parse_resolved(reader.text(posixpath.join(owner_dir, role + '.resolved'))) or None
913
+ model = item['model'] or {}
914
+ result = transcripts.transcript(log, model.get('harness'), running)
915
+ result.update(run=run_key(reader.path), execution=name, role=role, state=item['state'], attempt=item['attempt'],
916
+ spec=model.get('spec'), harness=model.get('harness'), repo=(resolved or {}).get('repo') or None,
917
+ owner=owner, warnings=reader.warnings + result['warnings'])
918
+ return result
919
+
920
+
921
+ def final_review_detail(path, name, source='plan'):
922
+ """FinalReviewDetail of final-review-<e>-<p> or integrate-<e>-<p>, or None."""
923
+ reader = _Read(path)
924
+ if _kind(reader.path) != 'plan':
925
+ return None
926
+ items = executions.review_dirs(reader)[0]
927
+ index = next((i for i, item in enumerate(items) if item[0] == name), None)
928
+ if index is None:
929
+ return None
930
+ summary, _, meta = _final_review(reader, items[index], index == 0, _published(reader))
931
+ rel = items[index][3]
932
+ qa_last = reader.text(rel + '/qa.last')
933
+ result = dict(summary)
934
+ result.update(meta=meta, tasks_summary=reader.excerpt(rel + '/tasks.summary', 20000, 8000, 'tasks_summary'),
935
+ qa_input=reader.excerpt(rel + '/qa.input', 40000, 20000, 'qa_input'),
936
+ qa_last=reader.clip(qa_last, 20000, 8000, 'qa_last'),
937
+ known_issues=reader.excerpt(rel + '/known_issues.md', 20000, 8000, 'known_issues'),
938
+ findings=findings.listing(qa_last), truncated=reader.truncated, warnings=reader.warnings)
939
+ return result
940
+
941
+
942
+ # --- diffs: what a task changed and what each review read, its findings pinned -------------
943
+
944
+ _PLAN_DIFFS = (('current', 'changes.diff'), ('prev', 'changes.prev.diff'), ('committed', 'committed.diff'))
945
+ _SOLO_DIFFS = (('current', 'changes.diff'), ('existing', 'existing.diff'))
946
+ _NOT_INLINED = 'not inlined (larger than 400 KB)'
947
+
948
+
949
+ def _url(key, *parts):
950
+ return '/api/runs/' + key + ''.join('/' + urllib.parse.quote(part, safe='') for part in parts)
951
+
952
+
953
+ def _first(reader, rels):
954
+ return next((rel for rel in rels if os.path.lexists(reader.path / rel)), None)
955
+
956
+
957
+ def _regular(reader, rel):
958
+ """fstat of a regular file under the run dir, quietly; None otherwise."""
959
+ try:
960
+ real = os.path.realpath(reader.path / rel)
961
+ info = os.stat(real)
962
+ except (OSError, ValueError):
963
+ return None
964
+ return info if executions.inside(reader.path, real) and stat.S_ISREG(info.st_mode) else None
965
+
966
+
967
+ def _diff_review(kind, item, attempt, spec, verdict, last, prompts, execution=None):
968
+ return dict(kind=kind, item=item, execution=item['id'] if item is not None else execution, attempt=attempt,
969
+ spec=spec, verdict=verdict, last=last, prompts=prompts)
970
+
971
+
972
+ def _prompts(item, owner_dir):
973
+ """Where the diff a qa execution reviewed is: its prompt copy, made when it finishes; while it
974
+ runs, what its dispatch works from."""
975
+ prompts = [item.rel + '/qa.prompt']
976
+ if item['state'] == 'running':
977
+ prompts += [posixpath.join(owner_dir, 'qa.' + suffix) for suffix in ('delivered', 'prompt', 'input')]
978
+ return prompts
979
+
980
+
981
+ def _diff_task(reader, task_id):
982
+ """What a task's diffs need: dir, diff files, attempt, executions and every review; None if no such task."""
983
+ kind = _kind(reader.path)
984
+ if kind == 'solo' and task_id == 'solo':
985
+ attempts = _history(reader, 'attempts.tsv')
986
+ execs = _solo_execs(reader, _solo_status(reader), attempts)
987
+ base, names, shadows = '', _SOLO_DIFFS, []
988
+ attempt, spec = max((item['attempt'] for item in attempts), default=None), _role_spec(reader, 'qa')
989
+ elif kind == 'plan':
990
+ task = _plan_task(reader, _tasks(reader), task_id)
991
+ if task is None:
992
+ return None
993
+ base, names = f'tasks/{task_id}', _PLAN_DIFFS
994
+ execs = _task_execs(reader, task)
995
+ shadows = _shadows(reader, task_id, execs)
996
+ attempt, spec = task['attempt'], task['qa']
997
+ else:
998
+ return None
999
+ reviews, copies = [], set()
1000
+ for item in execs:
1001
+ if item['role'] == 'qa' and not item['shadow']:
1002
+ last = item.rel + '/qa.last' if item['state'] == 'done' else None
1003
+ copies.add(reader.text(last) if last else None)
1004
+ reviews.append(_diff_review('primary', item, item['attempt'], (item['model'] or {}).get('spec'),
1005
+ item['verdict'], last, _prompts(item, base)))
1006
+ live = posixpath.join(base, 'qa.last')
1007
+ text = reader.text(live)
1008
+ # As in TaskDetail: the task's own qa.last counts only when no execution's copy is identical.
1009
+ if text is not None and text not in copies:
1010
+ reviews.append(_diff_review('primary', None, attempt, spec, _verdict(text), live, []))
1011
+ shadowed = []
1012
+ for entry, items, sub in shadows:
1013
+ run = items[-1] if items else None
1014
+ shadowed += items
1015
+ reviews.append(_diff_review('shadow', run, entry['attempt'], entry['spec'] if isinstance(entry['spec'], str)
1016
+ else None, entry['verdict'], sub + '/qa.last',
1017
+ ([run.rel + '/qa.prompt'] if run else []) + [sub + '/qa.input']))
1018
+ return dict(kind='task', id=task_id, dir=base, names=names, attempt=attempt, execs=execs + shadowed,
1019
+ reviews=reviews)
1020
+
1021
+
1022
+ def _diff_final(reader, name):
1023
+ items = executions.review_dirs(reader)[0]
1024
+ index = next((i for i, item in enumerate(items) if item[0] == name), None)
1025
+ if index is None:
1026
+ return None
1027
+ summary, execs, _ = _final_review(reader, items[index], index == 0, _published(reader))
1028
+ reviews = [_diff_review('final', item, None, summary['spec'] or (item['model'] or {}).get('spec'),
1029
+ item['verdict'], item.rel + '/qa.last' if item['state'] == 'done' else None,
1030
+ [item.rel + '/qa.prompt'])
1031
+ for item in execs]
1032
+ return dict(kind='final_review', id=name, dir=items[index][3], items=items, summary=summary, execs=execs,
1033
+ reviews=reviews)
1034
+
1035
+
1036
+ def _diff_sources(reader, key, ctx):
1037
+ sources = []
1038
+ if ctx['kind'] == 'final_review':
1039
+ for name, _, _, rel in ctx['items'][:_MAX_REVIEWS]:
1040
+ info = _regular(reader, rel + '/combined.diff')
1041
+ if info is not None or name == ctx['id']:
1042
+ sources.append(dict(id=name, label='combined.diff' if info else 'reviewed diff', kind='final',
1043
+ verdict=reader.word(rel + '/verdict'), bytes=info.st_size if info else None,
1044
+ url=_url(key, 'final-reviews', name, 'diff')))
1045
+ return sources
1046
+ for which, name in ctx['names']:
1047
+ info = _regular(reader, posixpath.join(ctx['dir'], name))
1048
+ if info is not None:
1049
+ sources.append(dict(id=which, label=name, bytes=info.st_size,
1050
+ url=_url(key, 'tasks', ctx['id'], 'diff', *([which] if which != 'current' else []))))
1051
+ for review in ctx['reviews']:
1052
+ rel = _first(reader, review['prompts']) if review['item'] is not None else None
1053
+ size = diffs.fence_bytes(reader, rel) if rel else None
1054
+ if size is not None:
1055
+ label = 'reviewed diff' if review['kind'] == 'primary' else 'shadow review'
1056
+ if review['attempt'] is not None:
1057
+ label += f', attempt {review["attempt"]}'
1058
+ sources.append(dict(id=review['execution'], label=label, attempt=review['attempt'],
1059
+ verdict=review['verdict'], kind=review['kind'], bytes=size,
1060
+ url=_url(key, 'executions', review['execution'], 'diff')))
1061
+ return sources
1062
+
1063
+
1064
+ def _diff_detail(reader, key, origin, sources, loaded, reason, reviews):
1065
+ files = loaded['files'] if loaded is not None else []
1066
+ found = []
1067
+ for review in reviews:
1068
+ text = reader.text(review['last']) if review['last'] else None
1069
+ for index, item in enumerate(findings.listing(text) or []):
1070
+ found.append(dict(id=_finding_id(review, index), review={key: review[key] for key in ('kind', 'execution', 'attempt', 'spec')},
1071
+ severity=item['severity'], status=item['status'], location=item['location'],
1072
+ summary=item['summary'], text=item['text']))
1073
+ return dict(run=key, source=origin, sources=sources, available=loaded is not None,
1074
+ reason=None if loaded is not None else reason, bytes=loaded['bytes'] if loaded else None,
1075
+ truncated=bool(loaded and loaded['truncated']), files=files, findings=diffs.pin(found, files),
1076
+ warnings=reader.warnings, truncated_fields=reader.truncated)
1077
+
1078
+
1079
+ def _missing(reader, rel):
1080
+ return 'unreadable (see warnings)' if os.path.lexists(reader.path / rel) else 'no ' + posixpath.basename(rel)
1081
+
1082
+
1083
+ def task_diff(path, task_id, which='current', source='plan'):
1084
+ """DiffDetail of a task's changes.diff (`current`), changes.prev.diff, committed.diff or, for a
1085
+ solo run, existing.diff; None when the task or that kind of diff does not exist."""
1086
+ reader = _Read(path)
1087
+ ctx = _diff_task(reader, task_id)
1088
+ names = dict(ctx['names']) if ctx is not None else {}
1089
+ if which not in names:
1090
+ return None
1091
+ rel = posixpath.join(ctx['dir'], names[which])
1092
+ loaded = diffs.load(reader, rel)
1093
+ latest = ctx['attempt']
1094
+ if latest is None:
1095
+ latest = max((review['attempt'] for review in ctx['reviews'] if review['attempt'] is not None), default=None)
1096
+ key = run_key(reader.path)
1097
+ origin = dict(kind='task', id=task_id, which=which, execution=None,
1098
+ attempt=latest if which in ('current', 'committed') else None, file=rel)
1099
+ return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded, _missing(reader, rel),
1100
+ [review for review in ctx['reviews'] if review['attempt'] == latest])
1101
+
1102
+
1103
+ def execution_diff(path, name, source='plan'):
1104
+ """DiffDetail of the diff a qa execution reviewed (for a dwarf: its attempt's review), or None."""
1105
+ reader = _Read(path)
1106
+ kind = _kind(reader.path)
1107
+ if kind is None or not executions.NAME.fullmatch(name or '') or name.split('-', 1)[0] not in ('qa', 'dwarf'):
1108
+ return None
1109
+ rel = executions.locate(reader.path, name, [task['id'] for task in _tasks(reader)] if kind == 'plan' else [])
1110
+ if rel is None:
1111
+ return None
1112
+ parts = rel.parts
1113
+ if parts[0] == 'attempts':
1114
+ ctx = _diff_task(reader, 'solo') if kind == 'solo' else None
1115
+ elif parts[0] in ('tasks', 'shadow'):
1116
+ ctx = _diff_task(reader, parts[1])
1117
+ else:
1118
+ ctx = _diff_final(reader, parts[0])
1119
+ item = next((candidate for candidate in ctx['execs'] if candidate['id'] == name), None) if ctx else None
1120
+ if item is None:
1121
+ return None
1122
+ review = next((review for review in ctx['reviews'] if review['execution'] == name), None)
1123
+ if review is None and item['role'] == 'qa': # an older run of a shadow dir
1124
+ review = _diff_review('shadow', item, item['attempt'], (item['model'] or {}).get('spec'), item['verdict'],
1125
+ item.rel + '/qa.last' if item['state'] == 'done' else None, [item.rel + '/qa.prompt'])
1126
+ elif review is None:
1127
+ # A dwarf's diff is the one its attempt's review read.
1128
+ def same(other):
1129
+ if item['parent']:
1130
+ return other['parent'] == item['parent']
1131
+ return item['attempt'] is not None and other['attempt'] == item['attempt']
1132
+ matches = [other for other in ctx['reviews']
1133
+ if other['kind'] == 'primary' and other['item'] and same(other['item'])]
1134
+ review = matches[-1] if matches else None
1135
+ prompt = _first(reader, review['prompts']) if review else None
1136
+ loaded = diffs.load(reader, prompt, fence=True) if prompt else None
1137
+ if review is None:
1138
+ reason = 'no review of this attempt'
1139
+ elif prompt is None:
1140
+ reason = 'no review prompt'
1141
+ elif _regular(reader, prompt) is None:
1142
+ reason = 'unreadable (see warnings)'
1143
+ else:
1144
+ reason = _NOT_INLINED if ctx['kind'] == 'final_review' else 'no diff in the review prompt'
1145
+ reviews = [review] if review else []
1146
+ if review and review['kind'] == 'primary' and review['attempt'] is not None:
1147
+ reviews += [other for other in ctx['reviews']
1148
+ if other['kind'] == 'shadow' and other['attempt'] == review['attempt']]
1149
+ key = run_key(reader.path)
1150
+ origin = dict(kind='execution', id=name, which='reviewed', execution=review['execution'] if review else None,
1151
+ attempt=review['attempt'] if review else item['attempt'], file=prompt)
1152
+ return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded, reason, reviews)
1153
+
1154
+
1155
+ def final_review_diff(path, name, source='plan'):
1156
+ """DiffDetail of a final review: combined.diff, else the ```diff fence of its prompt, or None."""
1157
+ reader = _Read(path)
1158
+ if _kind(reader.path) != 'plan':
1159
+ return None
1160
+ ctx = _diff_final(reader, name)
1161
+ if ctx is None:
1162
+ return None
1163
+ base, summary = ctx['dir'], ctx['summary']
1164
+ candidates = [(base + '/combined.diff', False)]
1165
+ candidates += [(review['prompts'][0], True) for review in reversed(ctx['reviews'])] + [(base + '/qa.input', True)]
1166
+ loaded = rel = None
1167
+ for rel, fence in candidates:
1168
+ if os.path.lexists(reader.path / rel):
1169
+ loaded = diffs.load(reader, rel, fence)
1170
+ if loaded is not None:
1171
+ break
1172
+ prompted = any(os.path.lexists(reader.path / candidate) for candidate, fence in candidates if fence)
1173
+ key = run_key(reader.path)
1174
+ origin = dict(kind='final_review', id=name, which='reviewed', execution=summary['execution'], attempt=None,
1175
+ file=rel if loaded is not None else None)
1176
+ review = _diff_review('final', None, None, summary['spec'], summary['verdict'], base + '/qa.last', [],
1177
+ summary['execution'])
1178
+ return _diff_detail(reader, key, origin, _diff_sources(reader, key, ctx), loaded,
1179
+ _NOT_INLINED if prompted else 'no review prompt yet', [review])
1180
+
1181
+
1182
+ # --- findings: every review's findings of a run, for the stage's marks ---------------------
1183
+
1184
+ def _finding_id(review, index):
1185
+ """A Finding's id: its review's execution (its kind when it has none) and its place in that reply."""
1186
+ return f"{review['execution'] or review['kind']}:{index}"
1187
+
1188
+
1189
+ def _findings_of(reader, review):
1190
+ listed = findings.listing(reader.text(review['last'])) if review['last'] else None
1191
+ return [dict(id=_finding_id(review, index), severity=item['severity'], status=item['status'],
1192
+ location=item['location'], summary=item['summary'])
1193
+ for index, item in enumerate(listed or [])]
1194
+
1195
+
1196
+ def run_findings(path, source='plan'):
1197
+ """RunFindings: each review's Findings, without their text, by task and by final review; None if no run."""
1198
+ reader = _Read(path)
1199
+ kind = _kind(reader.path)
1200
+ if kind is None:
1201
+ return None
1202
+ keys = ('kind', 'execution', 'attempt', 'spec', 'verdict')
1203
+ tasks = []
1204
+ for task_id in ['solo'] if kind == 'solo' else [task['id'] for task in _tasks(reader)]:
1205
+ ctx = _diff_task(reader, task_id)
1206
+ reviews = [dict({key: review[key] for key in keys}, findings=found)
1207
+ for review in (ctx['reviews'] if ctx else []) for found in [_findings_of(reader, review)] if found]
1208
+ if reviews:
1209
+ tasks.append(dict(id=task_id, reviews=reviews))
1210
+ finals = []
1211
+ if kind == 'plan':
1212
+ items, published = executions.review_dirs(reader)[0], _published(reader)
1213
+ for index, item in enumerate(items[:_MAX_REVIEWS]):
1214
+ summary = _final_review(reader, item, index == 0, published)[0]
1215
+ # the same review final_review_diff pins, so a Finding's id opens it there
1216
+ review = _diff_review('final', None, None, summary['spec'], summary['verdict'], item[3] + '/qa.last', [],
1217
+ summary['execution'])
1218
+ found = _findings_of(reader, review)
1219
+ if found:
1220
+ finals.append(dict(name=item[0], execution=summary['execution'], spec=summary['spec'],
1221
+ verdict=summary['verdict'], findings=found))
1222
+ return dict(run=run_key(reader.path), available=findings.available(), tasks=tasks, final_reviews=finals,
1223
+ truncated=reader.truncated, warnings=reader.warnings)
1224
+
1225
+
1226
+ # --- files: who planned, changed, drifted into or conflicted on each file --------------------
1227
+
1228
+ _MAX_FILES, _MAX_TASKS, _MAX_OVERLAPS = 2000, 300, 5000
1229
+
1230
+
1231
+ def _lines(reader, name):
1232
+ return [line.strip() for line in (reader.text(name) or '').splitlines() if line.strip()]
1233
+
1234
+
1235
+ def _scope(path):
1236
+ return posixpath.normpath(path.replace('\\', '/'))
1237
+
1238
+
1239
+ def _covers(scope, path):
1240
+ return path == scope or path.startswith(scope + '/')
1241
+
1242
+
1243
+ def _overlap(left, right):
1244
+ """forge-schedule.py's overlap(): equal, one a dir prefix of the other, or either `.`."""
1245
+ return any(x == '.' or y == '.' or _covers(x, y) or _covers(y, x) for x in left for y in right)
1246
+
1247
+
1248
+ def files_detail(path, source='plan'):
1249
+ """Files of a run: which tasks planned, touched, drifted into or conflicted on each one."""
1250
+ reader = _Read(path)
1251
+ kind = _kind(reader.path)
1252
+ if kind is None:
1253
+ return None
1254
+ truncated, rows, wave_map, deferrals = False, [], {}, []
1255
+ if kind == 'solo':
1256
+ attempts = _history(reader, 'attempts.tsv')
1257
+ status = _solo_status(reader)
1258
+ rows.append((dict(id='solo', status=status, files=[]), '', _solo_execs(reader, status, attempts)))
1259
+ wave_map = {'solo': 1}
1260
+ else:
1261
+ tasks = _tasks(reader)
1262
+ if len(tasks) > _MAX_TASKS:
1263
+ tasks, truncated = tasks[:_MAX_TASKS], True
1264
+ wave_map = _waves(reader)[1]
1265
+ for task in tasks:
1266
+ task['attempts'], task['attempt'] = _attempt_state(reader, f'tasks/{task["id"]}')
1267
+ rows.append((task, f'tasks/{task["id"]}', _task_execs(reader, task)))
1268
+ deferrals = _lines(reader, 'deferrals.txt')
1269
+ entries, raw = [], {}
1270
+ for task, base, execs in rows:
1271
+ attempts = []
1272
+ for item in execs:
1273
+ if item['role'] == 'qa' and not item['shadow']:
1274
+ prompt = _first(reader, _prompts(item, base))
1275
+ paths = diffs.touched_of(reader, prompt, fence=True) if prompt else None
1276
+ if paths is not None:
1277
+ attempts.append(dict(attempt=item['attempt'], execution=item['id'], files=paths))
1278
+ planned = [_scope(entry) for entry in task['files']]
1279
+ raw[task['id']] = task['files']
1280
+ entry = dict(id=task['id'], wave=wave_map.get(task['id']), status=task['status'], planned=planned,
1281
+ planned_all='.' in planned, attempts=attempts,
1282
+ current=diffs.touched_of(reader, posixpath.join(base, 'changes.diff')) or [],
1283
+ drift=_lines(reader, base + '/drift.txt') if base else [],
1284
+ conflicts=_lines(reader, base + '/conflict.files') if base else [])
1285
+ if kind == 'solo':
1286
+ entry['existing'] = diffs.touched_of(reader, 'existing.diff') or []
1287
+ entries.append(entry)
1288
+ files, touched = {}, {}
1289
+
1290
+ def add(path):
1291
+ if path not in files:
1292
+ if len(files) >= _MAX_FILES:
1293
+ return None
1294
+ files[path] = dict(path=path, dir=posixpath.dirname(path), planned_by=[], touched_by=[], drift_by=[],
1295
+ conflict_by=[])
1296
+ return files[path]
1297
+ for entry in entries:
1298
+ touched[entry['id']] = set(entry['current']).union(*(item['files'] for item in entry['attempts']))
1299
+ for field, paths in (('touched_by', sorted(touched[entry['id']])), ('drift_by', entry['drift']),
1300
+ ('conflict_by', entry['conflicts'])):
1301
+ for name in paths:
1302
+ item = add(name)
1303
+ truncated = truncated or item is None
1304
+ if item is not None and entry['id'] not in item[field]:
1305
+ item[field].append(entry['id'])
1306
+ dirs = {}
1307
+ for entry in entries:
1308
+ for scope, given in zip(entry['planned'], raw[entry['id']]):
1309
+ if scope == '.':
1310
+ continue
1311
+ # A planned entry is a dir when it says so or holds a file the run changed.
1312
+ if given.endswith('/') or (scope not in files and any(_covers(scope, name) for name in files)):
1313
+ planners = dirs.setdefault(scope + '/', [])
1314
+ if entry['id'] not in planners:
1315
+ planners.append(entry['id'])
1316
+ elif add(scope) is None:
1317
+ truncated = True
1318
+ for item in files.values():
1319
+ item['planned_by'] = [entry['id'] for entry in entries
1320
+ if any(scope != '.' and _covers(scope, item['path']) for scope in entry['planned'])]
1321
+ overlaps = []
1322
+ for i, left in enumerate(entries):
1323
+ for right in entries[i + 1:]:
1324
+ planned = _overlap(left['planned'], right['planned'])
1325
+ shared = sorted(touched[left['id']] & touched[right['id']])
1326
+ if planned or shared:
1327
+ if len(overlaps) >= _MAX_OVERLAPS:
1328
+ truncated = True
1329
+ break
1330
+ overlaps.append(dict(a=left['id'], b=right['id'], planned=planned, touched=shared,
1331
+ same_wave=left['wave'] is not None and left['wave'] == right['wave']))
1332
+ return dict(run=run_key(reader.path), files=[files[name] for name in sorted(files)],
1333
+ planned_dirs=[dict(path=name, planned_by=dirs[name]) for name in sorted(dirs)], tasks=entries,
1334
+ overlaps=overlaps, deferrals=deferrals, truncated=truncated, warnings=reader.warnings)
1335
+
1336
+
1337
+ # --- Fractal: the child tasks inside a task ---------------------------------------------------
1338
+
1339
+ def _fractal(path, task_id, read, *args):
1340
+ reader = _Read(path)
1341
+ kind = _kind(reader.path)
1342
+ if kind == 'solo' and task_id == 'solo':
1343
+ target = str(reader.path)
1344
+ elif kind == 'plan' and any(task['id'] == task_id for task in _tasks(reader)):
1345
+ # forge hands Fractal the task dir as --run-dir, resolved: its realpath names the Fractal task.
1346
+ target = os.path.realpath(reader.path / 'tasks' / task_id)
1347
+ else:
1348
+ return None
1349
+ return read(reader, run_key(reader.path), task_id, target, *args)
1350
+
1351
+
1352
+ def fractal_detail(path, task_id, source='plan'):
1353
+ return _fractal(path, task_id, fractal.task)
1354
+
1355
+
1356
+ def fractal_node(path, task_id, node, source='plan'):
1357
+ return _fractal(path, task_id, fractal.node, node)
1358
+
1359
+
1360
+ def fractal_node_diff(path, task_id, node, source='plan'):
1361
+ return _fractal(path, task_id, fractal.node_diff, node)
1362
+
1363
+
1364
+ class Catalog:
1365
+ def __init__(self, plans=(), roots=(), registry=True, registry_dir=None):
1366
+ self.options = dict(plans=tuple(plans), roots=tuple(roots), registry=registry, registry_dir=registry_dir)
1367
+
1368
+ def runs(self):
1369
+ runs = []
1370
+ for path, source in discover(**self.options):
1371
+ result = summarize(path, source)
1372
+ if _kind(path):
1373
+ runs.append(result)
1374
+ return sorted(runs, key=lambda item: item['updated'] if item['updated'] is not None else float('-inf'), reverse=True)
1375
+
1376
+ def _find(self, key, read, *args):
1377
+ for path, source in discover(**self.options):
1378
+ if run_key(path) == key:
1379
+ result = read(path, *args, source=source)
1380
+ return result if _kind(path) else None
1381
+ return None
1382
+
1383
+ def get(self, key):
1384
+ return self._find(key, detail)
1385
+
1386
+ def task(self, key, task_id):
1387
+ return self._find(key, task_detail, task_id)
1388
+
1389
+ def execution(self, key, name):
1390
+ return self._find(key, execution_detail, name)
1391
+
1392
+ def final_review(self, key, name):
1393
+ return self._find(key, final_review_detail, name)
1394
+
1395
+ def diff(self, key, task_id, which='current'):
1396
+ return self._find(key, task_diff, task_id, which)
1397
+
1398
+ def execution_diff(self, key, name):
1399
+ return self._find(key, execution_diff, name)
1400
+
1401
+ def transcript(self, key, name):
1402
+ return self._find(key, execution_transcript, name)
1403
+
1404
+ def final_review_diff(self, key, name):
1405
+ return self._find(key, final_review_diff, name)
1406
+
1407
+ def files(self, key):
1408
+ return self._find(key, files_detail)
1409
+
1410
+ def findings(self, key):
1411
+ return self._find(key, run_findings)
1412
+
1413
+ def fractal(self, key, task_id):
1414
+ return self._find(key, fractal_detail, task_id)
1415
+
1416
+ def fractal_node(self, key, task_id, node):
1417
+ return self._find(key, fractal_node, task_id, node)
1418
+
1419
+ def fractal_node_diff(self, key, task_id, node):
1420
+ return self._find(key, fractal_node_diff, task_id, node)
1421
+
1422
+ def models(self):
1423
+ from forge_ui import models # it reads runs through this module
1424
+ return models.collect(discover(**self.options))