@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,436 @@
1
+ """Executions: the attempts/<role>-<uuid>/ dirs forge-runtime.py writes, read without modifying them."""
2
+ from __future__ import annotations
3
+
4
+ from collections import OrderedDict
5
+ import hashlib
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ import posixpath
10
+ import re
11
+ import threading
12
+ import time
13
+
14
+ from forge_ui import findings
15
+
16
+
17
+ NAME = re.compile(r'(task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
18
+ _KIND = re.compile(r'(final-review|integrate)-([0-9]+)-([0-9]+)')
19
+ _SHADOW = re.compile(r'[0-9]+-[0-9]+')
20
+ CONTAINERS = ('task', 'solo')
21
+ _MODEL_ROLES = ('dwarf', 'qa')
22
+ # attempts.tsv is appended right after the task execution's EXIT trap ran finish().
23
+ _MATCH_S = 120
24
+ # A rewrite within one mtime tick that keeps the size keeps the signature too, so a file
25
+ # this young is parsed again on every read instead of being cached.
26
+ _SETTLE_S = 3
27
+ _REQUIRED = ('started', 'wall_started', 'phase', 'phase_started', 'elapsed_s', 'exit_code')
28
+
29
+
30
+ class _SigCache:
31
+ """LRU of parsed values, valid while a file keeps its (dev, inode, size, mtime_ns)."""
32
+
33
+ def __init__(self, size=8192):
34
+ self.size = size
35
+ self.lock = threading.Lock()
36
+ self.items = OrderedDict()
37
+
38
+ def get(self, key, sig):
39
+ with self.lock:
40
+ item = self.items.get(key)
41
+ if item is None or item[0] != sig:
42
+ return None
43
+ self.items.move_to_end(key)
44
+ return item[1]
45
+
46
+ def put(self, key, sig, value):
47
+ with self.lock:
48
+ self.items[key] = (sig, value)
49
+ self.items.move_to_end(key)
50
+ while len(self.items) > self.size:
51
+ self.items.popitem(last=False)
52
+
53
+
54
+ _CACHE = _SigCache()
55
+
56
+
57
+ class Execution(dict):
58
+ """An Execution as the API returns it. `dataset` and `rel` (its dir) stay out of the JSON."""
59
+ __slots__ = ('dataset', 'rel')
60
+
61
+
62
+ def inside(root, path) -> bool:
63
+ try:
64
+ return os.path.commonpath([str(root), str(path)]) == str(root)
65
+ except ValueError:
66
+ return False
67
+
68
+
69
+ def _signature(info):
70
+ return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)
71
+
72
+
73
+ def _cached(reader, name, kind, parse):
74
+ """parse(text) of one file through the cache; a hit replays the warnings the parse gave."""
75
+ path = reader.path / name
76
+ try:
77
+ info = path.stat()
78
+ except FileNotFoundError:
79
+ if os.path.lexists(path):
80
+ reader.warn(name, 'dangling symlink')
81
+ return None
82
+ except (OSError, ValueError) as exc:
83
+ reader.warn(name, f'cannot stat ({type(exc).__name__})')
84
+ return None
85
+ key, sig = (kind, str(path)), _signature(info)
86
+ hit = _CACHE.get(key, sig)
87
+ if hit is not None:
88
+ reader.present_files.add(name)
89
+ reader.track(info)
90
+ for reason in hit[1]:
91
+ reader.warn(name, reason)
92
+ return hit[0]
93
+ mark = len(reader.warnings)
94
+ text = reader.text(name)
95
+ value = None if text is None else parse(text)
96
+ new = reader.warnings[mark:]
97
+ prefix = name + ': '
98
+ if text is not None and all(item.startswith(prefix) for item in new) and abs(time.time() - info.st_mtime) > _SETTLE_S:
99
+ try:
100
+ if _signature(path.stat()) == sig:
101
+ _CACHE.put(key, sig, (value, [item[len(prefix):] for item in new]))
102
+ except (OSError, ValueError):
103
+ pass
104
+ return value
105
+
106
+
107
+ def summary_of(text):
108
+ """FindingsSummary of a reply, memoised by content (live files are re-read, not re-parsed)."""
109
+ if not text or not findings.available():
110
+ return None
111
+ key = ('summary', hashlib.sha1(text.encode('utf-8', 'surrogatepass')).hexdigest())
112
+ hit = _CACHE.get(key, 0)
113
+ if hit is None:
114
+ hit = (findings.summarize(text),)
115
+ if hit[0] is not None:
116
+ _CACHE.put(key, 0, hit)
117
+ return dict(hit[0]) if hit[0] is not None else None
118
+
119
+
120
+ def parse_resolved(text) -> dict:
121
+ result = {}
122
+ for line in (text or '').splitlines():
123
+ key, sep, value = line.partition('=')
124
+ if sep and key.strip():
125
+ result[key.strip()] = value.strip()
126
+ return result
127
+
128
+
129
+ def spec_of(resolved):
130
+ alias, effort = resolved.get('alias') or None, resolved.get('effort') or None
131
+ if not alias:
132
+ return None
133
+ # `<harness default>` is the dispatch placeholder, not an effort a caller can select.
134
+ if effort and effort != '<harness default>':
135
+ return alias + ':' + effort
136
+ return alias
137
+
138
+
139
+ def _model(resolved):
140
+ if not resolved or not resolved.get('alias'):
141
+ return None
142
+ effort = resolved.get('effort') or None
143
+ return dict(spec=spec_of(resolved), alias=resolved['alias'], harness=resolved.get('harness') or None,
144
+ model=resolved.get('model') or None,
145
+ effort=None if effort == '<harness default>' else effort,
146
+ clamped=resolved.get('clamped') or None)
147
+
148
+
149
+ def _finite(value):
150
+ return value if value is not None and math.isfinite(value) else None
151
+
152
+
153
+ def _metrics(reader, name, text):
154
+ obj = reader.object(name, text)
155
+ if obj is None:
156
+ return None
157
+ for key in _REQUIRED:
158
+ if key not in obj:
159
+ reader.warn(name, f'missing {key}')
160
+
161
+ def number(key, value, kinds=(int, float)):
162
+ if value is None:
163
+ return None
164
+ if type(value) in kinds: # bool is not a number here
165
+ if kinds == (int,):
166
+ return value
167
+ try:
168
+ return float(value)
169
+ except OverflowError:
170
+ pass
171
+ reader.warn(name, f'{key} is not ' + ('an integer' if kinds == (int,) else 'a number'))
172
+ return None
173
+
174
+ wall = number('wall_started', obj.get('wall_started'))
175
+ mono = number('started', obj.get('started'))
176
+ mark = number('phase_started', obj.get('phase_started'))
177
+ exit_code = number('exit_code', obj.get('exit_code'), (int,))
178
+ phases, total = {}, None
179
+ elapsed = obj.get('elapsed_s')
180
+ if isinstance(elapsed, dict):
181
+ for key, value in elapsed.items():
182
+ value = number('elapsed_s.' + key, value)
183
+ if value is None:
184
+ continue
185
+ if key == 'total':
186
+ total = value
187
+ else:
188
+ phases[key] = value
189
+ elif elapsed is not None:
190
+ reader.warn(name, 'elapsed_s is not an object')
191
+ phase = obj.get('phase')
192
+ if phase is not None and not isinstance(phase, str):
193
+ reader.warn(name, 'phase is not a string')
194
+ phase = None
195
+ tokens = {short: number(key, obj.get(key), (int,)) for short, key in (
196
+ ('input', 'input_tokens'), ('cached', 'cached_input_tokens'), ('output', 'output_tokens'))}
197
+ infra = obj.get('infra_class')
198
+ done = exit_code is not None
199
+ return dict(started=wall, ended=_finite(wall + total) if done and None not in (wall, total) else None,
200
+ duration_s=total if done else None, phase=phase,
201
+ phase_started=_finite(wall + (mark - mono)) if None not in (wall, mark, mono) else None,
202
+ phases=phases, exit_code=exit_code, infra_class=infra if isinstance(infra, str) and infra else None,
203
+ tokens=tokens if any(value is not None for value in tokens.values()) else None,
204
+ prompt_bytes=number('prompt_bytes', obj.get('prompt_bytes'), (int,)), shadow=obj.get('shadow') is True)
205
+
206
+
207
+ def _dataset(reader, name, text):
208
+ obj = reader.object(name, text)
209
+ if obj is None:
210
+ return None
211
+
212
+ def word(key):
213
+ value = obj.get(key)
214
+ return value if isinstance(value, str) and value else None
215
+
216
+ start = obj.get('attempt_start')
217
+ return dict(trigger=word('trigger'), parent=word('parent'),
218
+ attempt_start=start if type(start) is int else None, resume=word('resume'))
219
+
220
+
221
+ def qa_result(reader, name):
222
+ """(verdict, FindingsSummary) of a reply that no longer changes once written (cached)."""
223
+ kind = 'qa' if findings.available() else 'qa-verdict'
224
+ verdict, summary = _cached(reader, name, kind,
225
+ lambda text: (findings.verdict(text), findings.summarize(text))) or (None, None)
226
+ return verdict, dict(summary) if summary is not None else None
227
+
228
+
229
+ def resolved_of(reader, name):
230
+ return _cached(reader, name, 'resolved', parse_resolved)
231
+
232
+
233
+ def real_dir(reader, rel):
234
+ """realpath of a dir under the run, or None when it resolves outside it."""
235
+ try:
236
+ real = os.path.realpath(reader.path / rel)
237
+ except (OSError, ValueError):
238
+ return None
239
+ if not inside(reader.path, real):
240
+ if os.path.isdir(real):
241
+ reader.warn(rel, 'outside the run dir')
242
+ return None
243
+ return real
244
+
245
+
246
+ def scan(reader, rel_dir, owner_live, roles=None):
247
+ """Every execution in <rel_dir>, ordered by start (unknown last), then id."""
248
+ base = real_dir(reader, rel_dir)
249
+ if base is None:
250
+ return []
251
+ try:
252
+ with os.scandir(base) as stream:
253
+ entries = sorted((entry for entry in stream if NAME.fullmatch(entry.name)), key=lambda entry: entry.name)
254
+ except (FileNotFoundError, NotADirectoryError):
255
+ return []
256
+ except (OSError, ValueError) as exc:
257
+ reader.warn(rel_dir, f'cannot list ({type(exc).__name__})')
258
+ return []
259
+ result = []
260
+ for entry in entries:
261
+ role = entry.name.split('-', 1)[0]
262
+ rel = f'{rel_dir}/{entry.name}'
263
+ try:
264
+ if (roles and role not in roles) or not entry.is_dir():
265
+ continue
266
+ if entry.is_symlink() and not inside(reader.path, os.path.realpath(entry.path)):
267
+ reader.warn(rel, 'outside the run dir')
268
+ continue
269
+ except (OSError, ValueError):
270
+ continue
271
+ result.append(_build(reader, rel, entry.name, role))
272
+ result.sort(key=lambda item: (item['started'] is None, item['started'] or 0.0, item['id']))
273
+ newest = {item['role']: item['id'] for item in result}
274
+ for item in result:
275
+ if item['exit_code'] is not None:
276
+ item['state'] = 'done'
277
+ else:
278
+ live = owner_live(item['role']) if callable(owner_live) else owner_live
279
+ # Only the newest execution of a role can still be in flight: dispatches of one
280
+ # role into one dir run one after another.
281
+ item['state'] = 'running' if live and newest[item['role']] == item['id'] else 'orphaned'
282
+ owner = posixpath.dirname(rel_dir)
283
+ for item in result:
284
+ if item['role'] in _MODEL_ROLES and item['state'] == 'running' and item['model'] is None:
285
+ live = parse_resolved(reader.text(posixpath.join(owner, item['role'] + '.resolved')))
286
+ item['model'] = _model(live)
287
+ if item['role'] == 'qa' and item['state'] == 'done':
288
+ item['verdict'], item['findings'] = qa_result(reader, item.rel + '/qa.last')
289
+ return result
290
+
291
+
292
+ def _build(reader, rel, name, role):
293
+ metrics_name, dataset_name = rel + '/metrics.json', rel + '/dataset.json'
294
+ metrics = _cached(reader, metrics_name, 'metrics', lambda text: _metrics(reader, metrics_name, text)) or {}
295
+ dataset = _cached(reader, dataset_name, 'dataset', lambda text: _dataset(reader, dataset_name, text))
296
+ model = timed_out = None
297
+ if role in _MODEL_ROLES:
298
+ model = _model(resolved_of(reader, f'{rel}/{role}.resolved'))
299
+ timed_out = reader.exists(f'{rel}/{role}.timedout')
300
+ item = Execution(id=name, role=role, parent=None, attempt=None, shadow=bool(metrics.get('shadow')),
301
+ state=None, started=metrics.get('started'), ended=metrics.get('ended'),
302
+ duration_s=metrics.get('duration_s'), phase=metrics.get('phase'),
303
+ phase_started=metrics.get('phase_started'), phases=dict(metrics.get('phases') or {}),
304
+ exit_code=metrics.get('exit_code'), infra_class=metrics.get('infra_class'),
305
+ timed_out=bool(timed_out), model=model,
306
+ tokens=dict(metrics['tokens']) if metrics.get('tokens') else None,
307
+ prompt_bytes=metrics.get('prompt_bytes'), verdict=None, findings=None)
308
+ item.dataset, item.rel = dataset, rel
309
+ return item
310
+
311
+
312
+ def nest(execs, containers=None, adopt_state=True):
313
+ """Give each dwarf/qa execution its parent task (or solo) execution.
314
+
315
+ The parent dataset.json names wins; otherwise the latest container whose run contains
316
+ this execution's start. A child cannot run on after its parent stopped.
317
+ """
318
+ pool = [item for item in (execs if containers is None else containers) if item['role'] in CONTAINERS]
319
+ by_id = {item['id']: item for item in pool}
320
+ for item in execs:
321
+ if item['role'] in CONTAINERS:
322
+ continue
323
+ parent = by_id.get((item.dataset or {}).get('parent'))
324
+ if parent is None and item['started'] is not None:
325
+ for candidate in pool:
326
+ if (candidate['started'] is not None and candidate['started'] <= item['started']
327
+ and (candidate['ended'] is None or item['started'] <= candidate['ended'])):
328
+ parent = candidate
329
+ item['parent'] = parent['id'] if parent is not None else None
330
+ if adopt_state and parent is not None and item['state'] == 'running' and parent['state'] != 'running':
331
+ item['state'] = 'orphaned'
332
+ return execs
333
+
334
+
335
+ def assign_attempts(execs, attempt_rows, current=None):
336
+ """Attempt numbers: attempts.tsv rows by end time, then dataset.json, then `current` while running."""
337
+ pool = [item for item in execs if item['role'] in CONTAINERS]
338
+ pairs = sorted((abs(item['ended'] - row['at']), i, j)
339
+ for i, item in enumerate(pool) if item['ended'] is not None
340
+ for j, row in enumerate(attempt_rows) if abs(item['ended'] - row['at']) <= _MATCH_S)
341
+ taken, used = set(), set()
342
+ for _, i, j in pairs:
343
+ if i not in taken and j not in used:
344
+ taken.add(i)
345
+ used.add(j)
346
+ pool[i]['attempt'] = attempt_rows[j]['attempt']
347
+ for i, item in enumerate(pool):
348
+ if i in taken:
349
+ continue
350
+ start = (item.dataset or {}).get('attempt_start')
351
+ if start is not None:
352
+ # begin() runs before the counter moves; a QA-only resume does not move it.
353
+ item['attempt'] = start if (item.dataset or {}).get('resume') else start + 1
354
+ elif item['state'] == 'running':
355
+ item['attempt'] = current
356
+ by_id = {item['id']: item for item in pool}
357
+ for item in execs:
358
+ if item['role'] not in CONTAINERS:
359
+ parent = by_id.get(item['parent'])
360
+ item['attempt'] = parent['attempt'] if parent is not None else item['attempt']
361
+ return execs
362
+
363
+
364
+ def review_dirs(reader):
365
+ """(name, source, at, rel) of every final review, newest first: final-review-<e>-<p>/ and
366
+ integrate-<e>-<p>/final-review/. Also every integrate-<e>-<p>/, newest first, as
367
+ (name, at, its review's tuple or None)."""
368
+ try:
369
+ with os.scandir(reader.path) as stream:
370
+ names = sorted(entry.name for entry in stream if _KIND.fullmatch(entry.name))
371
+ except (OSError, ValueError) as exc:
372
+ reader.warn('.', f'cannot list ({type(exc).__name__})')
373
+ return [], []
374
+ reviews, integrations = [], []
375
+ for name in names:
376
+ kind, epoch, pid = _KIND.fullmatch(name).groups()
377
+ at = float(epoch)
378
+ if not math.isfinite(at) or len(epoch) > 15:
379
+ reader.warn(name, 'invalid epoch')
380
+ continue
381
+ real = real_dir(reader, name)
382
+ if real is None or not os.path.isdir(real):
383
+ continue
384
+ order = (at, int(pid), name)
385
+ if kind == 'final-review':
386
+ reviews.append((order, (name, 'review', at, name)))
387
+ continue
388
+ sub = real_dir(reader, name + '/final-review')
389
+ review = (name, 'integrate', at, name + '/final-review') if sub is not None and os.path.isdir(sub) else None
390
+ integrations.append((order, (name, at, review)))
391
+ if review is not None:
392
+ reviews.append((order, review))
393
+ reviews.sort(key=lambda item: item[0], reverse=True)
394
+ integrations.sort(key=lambda item: item[0], reverse=True)
395
+ return [item for _, item in reviews], [item for _, item in integrations]
396
+
397
+
398
+ def locate(run_path, name, task_ids):
399
+ """Path (relative to the run dir) of execution `name`'s dir, or None.
400
+
401
+ Only the dirs executions are written to are looked at, never the whole tree, and a dir
402
+ that resolves outside the run (a symlinked attempts/, say) is never returned.
403
+ """
404
+ if not NAME.fullmatch(name or ''):
405
+ return None
406
+ root = os.path.realpath(run_path)
407
+
408
+ def listed(rel, pattern):
409
+ try:
410
+ return sorted(child for child in os.listdir(os.path.join(root, rel)) if pattern.fullmatch(child))
411
+ except (OSError, ValueError):
412
+ return []
413
+
414
+ def candidates():
415
+ yield 'attempts'
416
+ for ident in task_ids:
417
+ yield f'tasks/{ident}/attempts'
418
+ for ident in task_ids:
419
+ for child in listed(f'shadow/{ident}', _SHADOW):
420
+ yield f'shadow/{ident}/{child}/attempts'
421
+ kinds = listed('', _KIND)
422
+ for child in kinds:
423
+ if child.startswith('final-review-'):
424
+ yield child + '/attempts'
425
+ for child in kinds:
426
+ if child.startswith('integrate-'):
427
+ yield child + '/final-review/attempts'
428
+
429
+ for rel in candidates():
430
+ try:
431
+ real = os.path.realpath(os.path.join(root, rel, name))
432
+ if inside(root, real) and os.path.isdir(real):
433
+ return Path(rel) / name
434
+ except (OSError, ValueError):
435
+ continue
436
+ return None
@@ -0,0 +1,204 @@
1
+ """One run as one HTML file that opens anywhere, offline included.
2
+
3
+ The page's own styles and scripts are inlined, and so is every API response the run view reads,
4
+ taken through the server's own routes (server.api_get), so the page finds them under the paths it
5
+ asks for. A Content-Security-Policy in the page lets it run only those inline scripts (by hash)
6
+ and load nothing but three.js from its pinned CDN path. The 3D stage's modules (iso.js, cloud3d.js,
7
+ scene3d.js) travel as one module text, imported through a blob: URL; without the CDN the page
8
+ shows the run in 2D.
9
+
10
+ By default the export leaves out what could be private: prompts, reports, logs, finding texts
11
+ (their headlines stay) and diffs (texts=False), and reduces local paths to their last component
12
+ (paths=False).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ import re
22
+ import time
23
+ import urllib.parse
24
+
25
+ from forge_ui import __version__, server
26
+
27
+ STATIC = server.STATIC_DIR
28
+ # what the exported page runs, in index.html's order; the cloud, the leaderboard and notifications need a server
29
+ SCRIPTS = ('runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'files.js', 'map3d.js',
30
+ 'search.js', 'app.js')
31
+ MODULES = ('iso.js', 'cloud3d.js', 'scene3d.js') # bundled in dependency order
32
+ DIFF_CAP = 1 << 20 # one diff's JSON
33
+ TOTAL_CAP = 25 << 20 # every response together
34
+ CDN = 'https://cdn.jsdelivr.net/npm/three@0.160.0/'
35
+ _EXEC = re.compile(r'(?:task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
36
+ # texts that can hold code, prompts or private notes: nulled unless texts=True
37
+ _TEXT_KEYS = frozenset(('prompt', 'last', 'request', 'log_tail', 'dwarf_tail', 'qa_tail', 'qa_last', 'qa_input',
38
+ 'tasks_summary', 'known_issues', 'texts', 'text', 'planning_last', 'approach', 'retry_findings',
39
+ 'dwarf_input', 'error'))
40
+
41
+
42
+ def _enc(value):
43
+ """encodeURIComponent, as the page builds its request paths."""
44
+ return urllib.parse.quote(str(value), safe="!~*'()")
45
+
46
+
47
+ def _sha(text):
48
+ return "'sha256-%s'" % base64.b64encode(hashlib.sha256(text.encode('utf-8')).digest()).decode('ascii')
49
+
50
+
51
+ def _script_safe(text):
52
+ # inside a <script> element only "</script" (any case) and "<!--" can end or bend it
53
+ return re.sub(r'</(script)', r'<\\/\1', text, flags=re.IGNORECASE).replace('<!--', '<\\!--')
54
+
55
+
56
+ def bundle():
57
+ """The stage's ES modules as one module: third-party imports first, each module in its own scope,
58
+ relative imports bound to the earlier modules' exports, and createStage exported."""
59
+ imports, parts = [], []
60
+ for name in MODULES:
61
+ text = (STATIC / name).read_text(encoding='utf-8')
62
+ body, names, local = [], [], []
63
+ for line in text.split('\n'):
64
+ rel = re.match(r"import \{([^}]*)\} from '\./([A-Za-z0-9_]+)\.js';\s*$", line)
65
+ if rel:
66
+ local.append(f"const {{{rel.group(1)}}} = __{rel.group(2)};")
67
+ continue
68
+ if re.match(r"import .* from '[^.][^']*';\s*$", line):
69
+ if line not in imports:
70
+ imports.append(line)
71
+ continue
72
+ m = re.match(r'export (async function|function|const|let|class) ([A-Za-z_$][\w$]*)(.*)$', line)
73
+ if m:
74
+ names.append(m.group(2))
75
+ body.append(f'{m.group(1)} {m.group(2)}{m.group(3)}')
76
+ continue
77
+ m = re.match(r'export \{([^}]*)\};?\s*$', line)
78
+ if m:
79
+ names.extend(n.strip() for n in m.group(1).split(',') if n.strip())
80
+ continue
81
+ body.append(line)
82
+ stem = name[:-3]
83
+ parts.append(f"const __{stem} = (() => {{\n{chr(10).join(local)}\n{chr(10).join(body)}\nreturn {{ {', '.join(names)} }};\n}})();")
84
+ return '\n'.join(imports) + '\n' + '\n'.join(parts) + '\nconst { createStage } = __scene3d;\nexport { createStage };\n'
85
+
86
+
87
+ class _Snapshot:
88
+ def __init__(self, catalog, key, texts, paths):
89
+ self.catalog, self.key, self.texts, self.paths = catalog, key, texts, paths
90
+ self.api, self.omitted, self.size = {}, [], 0
91
+ self.roots = []
92
+
93
+ def add(self, path, cap=None, required=False):
94
+ if path in self.api:
95
+ return self.api[path]
96
+ value = server.api_get(self.catalog, path)
97
+ if value is server.NO_ROUTE or value is None:
98
+ return None
99
+ value = self.clean(value)
100
+ size = len(json.dumps(value, ensure_ascii=False).encode('utf-8'))
101
+ if not required and ((cap and size > cap) or self.size + size > TOTAL_CAP):
102
+ self.omitted.append({'path': path, 'bytes': size, 'why': 'larger than 1 MB' if cap and size > cap else 'over the 25 MB total'})
103
+ return None
104
+ self.api[path] = value
105
+ self.size += size
106
+ return value
107
+
108
+ def clean(self, value, key=None):
109
+ if isinstance(value, dict):
110
+ return {k: (None if not self.texts and k in _TEXT_KEYS and isinstance(v, (str, dict, list)) else self.clean(v, k))
111
+ for k, v in value.items()}
112
+ if isinstance(value, list):
113
+ return [self.clean(v, key) for v in value]
114
+ if isinstance(value, str) and not self.paths and key != 'url':
115
+ return self.hide(value)
116
+ return value
117
+
118
+ def hide(self, text):
119
+ # a whole absolute path becomes its last component; known roots inside longer texts are replaced too
120
+ if re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
121
+ return os.path.basename(text.rstrip('/\\')) or text
122
+ for root, name in self.roots:
123
+ if root in text:
124
+ text = text.replace(root, name)
125
+ return text
126
+
127
+
128
+ def build(catalog, key, texts=False, paths=False, now=None):
129
+ """(file name, HTML bytes) of run `key`, or None when there is no such run."""
130
+ base = f'/api/runs/{key}'
131
+ run = server.api_get(catalog, base)
132
+ if not isinstance(run, dict):
133
+ return None
134
+ snap = _Snapshot(catalog, key, texts, paths)
135
+ home = os.path.expanduser('~')
136
+ for root in (run.get('path'), run.get('repo')):
137
+ if isinstance(root, str) and root.startswith('/') and len(root) > 1:
138
+ snap.roots.append((root.rstrip('/'), os.path.basename(root.rstrip('/')) or root))
139
+ if home and home != '/':
140
+ snap.roots.append((home, '~'))
141
+ snap.roots.sort(key=lambda r: -len(r[0]))
142
+ summary = next((r for r in catalog.runs() if r.get('key') == key), None)
143
+ snap.api['/api/runs'] = {'runs': [snap.clean(summary)] if summary else []}
144
+ detail = snap.add(base, required=True)
145
+ tasks = [t for t in (detail.get('tasks') or []) if isinstance(t, dict) and t.get('id')]
146
+ for task in tasks:
147
+ snap.add(f"{base}/tasks/{_enc(task['id'])}")
148
+ for name in sorted(set(_EXEC.findall(json.dumps(run)))):
149
+ snap.add(f'{base}/executions/{name}')
150
+ reviews = [r.get('name') for r in (run.get('final_reviews') or []) if isinstance(r, dict) and r.get('name')]
151
+ for name in reviews:
152
+ snap.add(f'{base}/final-reviews/{_enc(name)}')
153
+ snap.add(f'{base}/files')
154
+ fractal = run.get('fractal') or {}
155
+ if fractal.get('enabled') and fractal.get('available'):
156
+ for task in tasks:
157
+ tree = snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal")
158
+ for node in (tree or {}).get('nodes') or []:
159
+ if isinstance(node, dict) and node.get('id') and not node.get('reserved'):
160
+ snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal/nodes/{_enc(node['id'])}")
161
+ if texts and node.get('has_diff'):
162
+ snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal/nodes/{_enc(node['id'])}/diff", cap=DIFF_CAP)
163
+ if texts:
164
+ # every diff a page of this run can open: each task's files and each review's, the final reviews'
165
+ for task in tasks:
166
+ first = snap.add(f"{base}/tasks/{_enc(task['id'])}/diff", cap=DIFF_CAP)
167
+ for source in (first or {}).get('sources') or []:
168
+ url = source.get('url') if isinstance(source, dict) else None
169
+ if isinstance(url, str) and url.startswith(base + '/'):
170
+ snap.add(url, cap=DIFF_CAP)
171
+ for name in reviews:
172
+ snap.add(f'{base}/final-reviews/{_enc(name)}/diff', cap=DIFF_CAP)
173
+ meta = {'format': 'forge-ui-export', 'version': 1, 'app': __version__, 'key': key,
174
+ 'generated': now if now is not None else time.time(), 'texts': bool(texts), 'paths': bool(paths),
175
+ 'omitted': snap.omitted}
176
+ # the title follows the same rules as the data: no local paths unless chosen
177
+ page = _page({'meta': meta, 'api': snap.api}, snap.clean({'goal': run.get('goal'), 'run_id': run.get('run_id')}))
178
+ stem = re.sub(r'[^A-Za-z0-9._-]+', '-', str(run.get('run_id') or key)).strip('.-')[:80] or key
179
+ return f'forge-run-{stem}.html', page.encode('utf-8')
180
+
181
+
182
+ def _page(snapshot, run):
183
+ index = (STATIC / 'index.html').read_text(encoding='utf-8')
184
+ importmap = re.search(r'<script type="importmap">(.*?)</script>', index, re.DOTALL).group(1)
185
+ body = re.search(r'<body>(.*)</body>', index, re.DOTALL).group(1)
186
+ css = (STATIC / 'style.css').read_text(encoding='utf-8')
187
+ scripts = [_script_safe((STATIC / name).read_text(encoding='utf-8')) for name in SCRIPTS if (STATIC / name).is_file()]
188
+ data = json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
189
+ stage = _script_safe(bundle())
190
+ icon = 'data:image/svg+xml;base64,' + base64.b64encode((STATIC / 'favicon.svg').read_bytes()).decode('ascii')
191
+ csp = ("default-src 'none'; script-src " + ' '.join([_sha(importmap)] + [_sha(s) for s in scripts]) + f" blob: {CDN}; "
192
+ f"style-src {_sha(css)}; img-src data: blob:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
193
+ title = 'forge-ui · ' + re.sub(r'[<>&"]', '', str(run.get('goal') or run.get('run_id') or 'run').split('\n')[0])[:120]
194
+ return ''.join([
195
+ '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n',
196
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta name="color-scheme" content="light dark">\n',
197
+ '<meta name="referrer" content="no-referrer">\n',
198
+ f'<meta http-equiv="Content-Security-Policy" content="{csp}">\n',
199
+ f'<title>{title}</title>\n<link rel="icon" href="{icon}">\n',
200
+ f'<script type="importmap">{importmap}</script>\n<style>{css}</style>\n</head>\n<body>{body}',
201
+ f'<script type="application/json" id="forge-snapshot">{data}</script>\n',
202
+ f'<script type="text/plain" id="forge-stage">{stage}</script>\n',
203
+ ''.join(f'<script>{s}</script>\n' for s in scripts),
204
+ '</body>\n</html>\n'])