@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.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""`forge dataset import`: capture runs that predate live capture, from dirs or archives.
|
|
2
|
+
|
|
3
|
+
It uses the same readers as live capture, so an execution imported here and later seen
|
|
4
|
+
live (or the reverse) collapses to one event by rid. What it cannot do is know which task
|
|
5
|
+
files an old execution wrote, because the begin() snapshot did not exist yet. So:
|
|
6
|
+
|
|
7
|
+
- the newest task attempt gets the task dir's current files, flagged `legacy`;
|
|
8
|
+
- earlier attempts get only what is immutable: their own dispatch dirs, the diff QA was
|
|
9
|
+
shown (the last ```diff block of the QA prompt), and the status attempts.tsv logged.
|
|
10
|
+
|
|
11
|
+
No historical outcome is inferred from a file that a later attempt could have rewritten.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import tarfile
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import config_runs_dir, data_home, put_blob, put_file, record, repo_id, sha256
|
|
25
|
+
from . import harvest as h
|
|
26
|
+
from . import capture
|
|
27
|
+
|
|
28
|
+
SKIP_DIRS = {'.git', 'node_modules', 'capabilities', '.forge-worktrees'}
|
|
29
|
+
_DIFF_BLOCK = re.compile(r'```diff\n(.*?)\n```', re.S)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _temp_roots() -> tuple:
|
|
33
|
+
roots = {os.path.realpath(tempfile.gettempdir()), '/tmp', '/private/tmp', '/var/folders', '/private/var/folders'}
|
|
34
|
+
return tuple(r.rstrip('/') + '/' for r in roots)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_temp(path: str | None) -> bool:
|
|
38
|
+
if not path:
|
|
39
|
+
return False
|
|
40
|
+
real = os.path.realpath(path) + '/'
|
|
41
|
+
# Agent scratchpads live under /tmp too, but a plan dir there still belongs to a real
|
|
42
|
+
# repo; only a REPO in a temp dir marks a test run.
|
|
43
|
+
return real.startswith(_temp_roots())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def walk_attempts(top: Path) -> list[Path]:
|
|
47
|
+
found = []
|
|
48
|
+
for directory, subdirs, _ in os.walk(top):
|
|
49
|
+
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-')]
|
|
50
|
+
if os.path.basename(directory) == 'attempts':
|
|
51
|
+
found.extend(Path(directory) / d for d in subdirs if '-' in d)
|
|
52
|
+
subdirs[:] = []
|
|
53
|
+
return sorted(found)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def walk_plans(top: Path) -> list[Path]:
|
|
57
|
+
found = []
|
|
58
|
+
for directory, subdirs, files in os.walk(top):
|
|
59
|
+
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-') and d != 'attempts']
|
|
60
|
+
if 'tasks.tsv' in files:
|
|
61
|
+
found.append(Path(directory))
|
|
62
|
+
return sorted(found)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def walk_fractal_tasks(top: Path) -> list[Path]:
|
|
66
|
+
found = []
|
|
67
|
+
for directory, subdirs, files in os.walk(top):
|
|
68
|
+
subdirs[:] = [d for d in subdirs if d not in SKIP_DIRS and not d.startswith('review-')
|
|
69
|
+
and d not in ('attempts', 'steps', 'runtime')]
|
|
70
|
+
if 'request.json' in files and 'nodes' in subdirs and Path(directory).parent.name == 'tasks':
|
|
71
|
+
found.append(Path(directory))
|
|
72
|
+
return sorted(found)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Mapper:
|
|
76
|
+
"""Maps a path inside an extracted archive back to where the run originally lived.
|
|
77
|
+
|
|
78
|
+
A run's legacy id hashes its original path, so importing an archive and importing
|
|
79
|
+
the original dir must agree on it.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.pairs = []
|
|
84
|
+
|
|
85
|
+
def add(self, extracted_parent: Path, original_parent: Path) -> None:
|
|
86
|
+
self.pairs.append((str(extracted_parent.resolve()), str(original_parent)))
|
|
87
|
+
|
|
88
|
+
def __call__(self, path: Path | str) -> Path:
|
|
89
|
+
text = str(Path(path).resolve())
|
|
90
|
+
for extracted, original in self.pairs:
|
|
91
|
+
if text == extracted or text.startswith(extracted + '/'):
|
|
92
|
+
return Path(original + text[len(extracted):])
|
|
93
|
+
return Path(text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _uid(run_dir: Path, mapper: Mapper, cache: dict) -> str:
|
|
97
|
+
key = str(run_dir)
|
|
98
|
+
if key not in cache:
|
|
99
|
+
stored = h.first_line(run_dir / 'dataset.uid') or _live_uid(run_dir, mapper(run_dir))
|
|
100
|
+
cache[key] = stored or h.legacy_uid_for(run_dir, mapper(run_dir))
|
|
101
|
+
return cache[key]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _live_uid(run_dir: Path, original: Path) -> str | None:
|
|
105
|
+
"""The id live capture gave this run, when it is still running where it was archived.
|
|
106
|
+
|
|
107
|
+
An archive taken mid-run has no dataset.uid, while the same dir on disk got one from
|
|
108
|
+
the first live begin(). The dir is the same run only if it still holds an execution
|
|
109
|
+
from the archive: /tmp plan paths get reused by later, unrelated runs.
|
|
110
|
+
"""
|
|
111
|
+
if original == run_dir.resolve() or not (original / 'dataset.uid').exists():
|
|
112
|
+
return None
|
|
113
|
+
for attempt in walk_attempts(run_dir)[:5]:
|
|
114
|
+
if (original / attempt.relative_to(run_dir)).is_dir():
|
|
115
|
+
return h.first_line(original / 'dataset.uid')
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _metrics(attempt: Path) -> dict:
|
|
120
|
+
return h.read_json(attempt / 'metrics.json') or {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _window(attempt: Path) -> tuple[float, float]:
|
|
124
|
+
metrics = _metrics(attempt)
|
|
125
|
+
start = metrics.get('wall_started') or 0
|
|
126
|
+
total = (metrics.get('elapsed_s') or {}).get('total') or 0
|
|
127
|
+
return start, start + total
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def legacy_task_events(tdir: Path, task_attempts: list[Path], prov_base: dict) -> list[dict]:
|
|
131
|
+
"""Task attempts of one task dir, oldest first; see the module docstring for what each gets."""
|
|
132
|
+
events = []
|
|
133
|
+
rows = []
|
|
134
|
+
for line in (h.text(tdir / 'attempts.tsv') or '').splitlines():
|
|
135
|
+
cells = line.split('\t')
|
|
136
|
+
if len(cells) >= 3 and cells[0].isdigit():
|
|
137
|
+
rows.append(dict(epoch=int(cells[0]), attempt=h.as_int(cells[1]), status=cells[2],
|
|
138
|
+
infra=cells[3] if len(cells) > 3 else ''))
|
|
139
|
+
dispatches = []
|
|
140
|
+
for path in sorted((tdir / 'attempts').glob('*-*')):
|
|
141
|
+
role = path.name.split('-', 1)[0]
|
|
142
|
+
if role in h.DISPATCH_ROLES:
|
|
143
|
+
dispatches.append((_window(path)[0], role, path))
|
|
144
|
+
ordered = sorted(task_attempts, key=lambda p: _window(p)[0])
|
|
145
|
+
for index, attempt in enumerate(ordered):
|
|
146
|
+
start, end = _window(attempt)
|
|
147
|
+
# The task dir's files belong to the newest attempt only if it finished; an attempt
|
|
148
|
+
# still running when the dir was copied is no owner of the files around it.
|
|
149
|
+
finished = _metrics(attempt).get('exit_code') is not None and h.first_line(tdir / 'status') != 'RUNNING'
|
|
150
|
+
latest = index == len(ordered) - 1 and finished
|
|
151
|
+
# A dispatch belongs to the task attempt that started last before it. The next
|
|
152
|
+
# attempt's start bounds the window even when this one was killed and never
|
|
153
|
+
# recorded its own end.
|
|
154
|
+
until = _window(ordered[index + 1])[0] if index + 1 < len(ordered) else float('inf')
|
|
155
|
+
prov = dict(prov_base, files={} if latest else None, wall_ended=end or None)
|
|
156
|
+
event = h.task_event(attempt, tdir, _metrics(attempt).get('exit_code'), prov, src='import')
|
|
157
|
+
children = [p.name for (t, _, p) in dispatches if start <= t < until]
|
|
158
|
+
row = next((r for r in rows if r['epoch'] >= int(start)), None)
|
|
159
|
+
if row:
|
|
160
|
+
rows.remove(row)
|
|
161
|
+
data = event['data']
|
|
162
|
+
data.update(legacy=True, children=children, logged=row)
|
|
163
|
+
qa = [p for (_, role, p) in sorted(dispatches) if role == 'qa' and p.name in children]
|
|
164
|
+
if qa:
|
|
165
|
+
data['blobs'].pop('forge_note', None)
|
|
166
|
+
if _metrics(attempt).get('exit_code') is None:
|
|
167
|
+
continue # unfinished: see import_tree
|
|
168
|
+
if not latest:
|
|
169
|
+
verdict = h.verdict_of(h.text(qa[-1] / 'qa.last')) if qa else None
|
|
170
|
+
data['status'], data['status_source'] = ((row['status'], 'attempts.tsv') if row else
|
|
171
|
+
(verdict, 'qa_verdict') if verdict else (None, None))
|
|
172
|
+
event['attempt'], data['attempt_source'] = ((row['attempt'], 'attempts.tsv') if row else
|
|
173
|
+
(index + 1, 'order'))
|
|
174
|
+
blocks = _DIFF_BLOCK.findall(h.text(qa[-1] / 'qa.prompt') or '') if qa else []
|
|
175
|
+
if blocks:
|
|
176
|
+
diff = blocks[-1]
|
|
177
|
+
data.update(h.diff_summary(diff), diff_source='qa_prompt')
|
|
178
|
+
data['blobs']['diff'] = put_blob(diff, 'diff')
|
|
179
|
+
else:
|
|
180
|
+
event['attempt'] = h.as_int(h.first_line(tdir / 'attempt'))
|
|
181
|
+
data['attempt_source'] = 'attempt_file'
|
|
182
|
+
events.append(event)
|
|
183
|
+
return events
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def import_tree(top: Path, mapper: Mapper, args, counts: dict) -> list[tuple[list, str | None]]:
|
|
187
|
+
"""Every event under `top`, grouped per repo: [(events, repo)]."""
|
|
188
|
+
batches = []
|
|
189
|
+
uids = {}
|
|
190
|
+
plans = {p for p in walk_plans(top)}
|
|
191
|
+
by_task: dict = {}
|
|
192
|
+
for attempt in walk_attempts(top):
|
|
193
|
+
role = attempt.name.split('-', 1)[0]
|
|
194
|
+
root = attempt.parent.parent
|
|
195
|
+
ctx = h.locate(root, role)
|
|
196
|
+
repo = ctx.get('repo')
|
|
197
|
+
if is_temp(repo) and not args.include_temp:
|
|
198
|
+
counts['skipped_temp'] = counts.get('skipped_temp', 0) + 1
|
|
199
|
+
continue
|
|
200
|
+
uid = _uid(ctx['run_dir'], mapper, uids)
|
|
201
|
+
stored = h.read_json(attempt / 'dataset.json')
|
|
202
|
+
prov = stored or dict(run=uid, run_id=ctx['run_id'], mode=ctx['mode'], scope=ctx['scope'],
|
|
203
|
+
task=ctx['task'], repo=repo, link=ctx['link'], files=None)
|
|
204
|
+
prov['root'] = str(mapper(root))
|
|
205
|
+
if role == 'task' and stored is None:
|
|
206
|
+
by_task.setdefault(str(root), (root, prov, repo, []))[3].append(attempt)
|
|
207
|
+
continue
|
|
208
|
+
metrics = _metrics(attempt)
|
|
209
|
+
# Unfinished: still running, or killed. Recording it now would win the rid over the
|
|
210
|
+
# real outcome that live capture (or a later import) records when it ends.
|
|
211
|
+
if metrics.get('exit_code') is None:
|
|
212
|
+
counts['skipped_unfinished'] = counts.get('skipped_unfinished', 0) + 1
|
|
213
|
+
continue
|
|
214
|
+
harness = 'pipeline' if role in h.PIPELINE_ROLES else (metrics.get('harness') or '')
|
|
215
|
+
prov.setdefault('wall_ended', _window(attempt)[1] or None)
|
|
216
|
+
if harness != 'pipeline' and prov.get('scope') == 'task' and stored is None:
|
|
217
|
+
prov['attempt_now'] = None
|
|
218
|
+
try:
|
|
219
|
+
events = capture.events_for(attempt, root, role, harness, metrics.get('exit_code'), prov, 'import')
|
|
220
|
+
except Exception as error:
|
|
221
|
+
counts.setdefault('failed', []).append('%s: %s' % (attempt, error))
|
|
222
|
+
continue
|
|
223
|
+
for event in events:
|
|
224
|
+
if isinstance(event.get('data'), dict) and event['data'].get('root'):
|
|
225
|
+
event['data']['root'] = str(mapper(event['data']['root']))
|
|
226
|
+
# A live begin() recorded which code the execution started with; keep it.
|
|
227
|
+
capture.stamp(events, (stored or {}).get('provenance'), check_change=False)
|
|
228
|
+
batches.append((events, repo))
|
|
229
|
+
for root, prov, repo, attempts in by_task.values():
|
|
230
|
+
try:
|
|
231
|
+
batches.append((legacy_task_events(root, attempts, dict(prov, mode='parallel', task=root.name)), repo))
|
|
232
|
+
except Exception as error:
|
|
233
|
+
counts.setdefault('failed', []).append('%s: %s' % (root, error))
|
|
234
|
+
for task_dir in walk_fractal_tasks(top):
|
|
235
|
+
try:
|
|
236
|
+
ctx = h.fractal_context(task_dir, create=False)
|
|
237
|
+
if ctx.get('run') and not (task_dir.parent.parent / 'dataset.uid').exists():
|
|
238
|
+
ctx['run'] = h.legacy_uid_for(task_dir.parent.parent, mapper(task_dir.parent.parent))
|
|
239
|
+
if is_temp(ctx['repo']) and not args.include_temp:
|
|
240
|
+
continue
|
|
241
|
+
events = h.fractal_task_events(task_dir, ctx, src='import')
|
|
242
|
+
for path in sorted(task_dir.glob('nodes/*/node.json')):
|
|
243
|
+
node = h.read_json(path) or {}
|
|
244
|
+
for step in sorted((path.parent / 'steps').glob('*')):
|
|
245
|
+
if step.is_dir():
|
|
246
|
+
events.append(h.fractal_step_event(task_dir, node, step, ctx, src='import'))
|
|
247
|
+
batches.append((events, ctx['repo']))
|
|
248
|
+
except Exception as error:
|
|
249
|
+
counts.setdefault('failed', []).append('%s: %s' % (task_dir, error))
|
|
250
|
+
for plan in sorted(plans):
|
|
251
|
+
repo = h.first_line(plan / 'repo')
|
|
252
|
+
if is_temp(repo) and not args.include_temp:
|
|
253
|
+
continue
|
|
254
|
+
uid = _uid(plan, mapper, uids)
|
|
255
|
+
prov = dict(run=uid, run_id=h.first_line(plan / 'run_id') or plan.name)
|
|
256
|
+
bases = [h.first_line(t / 'base_ref') for t in sorted((plan / 'tasks').glob('*')) if (t / 'base_ref').exists()]
|
|
257
|
+
events = [h.plan_event(plan, prov, src='import', head=bases[0] if bases else None)]
|
|
258
|
+
# A plan that has a dataset.uid was captured live, merges and final reviews included,
|
|
259
|
+
# so synthesizing them again would only add a second, differently keyed copy.
|
|
260
|
+
live = (plan / 'dataset.uid').exists()
|
|
261
|
+
for tdir in sorted((plan / 'tasks').glob('*')) if not live else []:
|
|
262
|
+
if not tdir.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
status = h.first_line(tdir / 'status')
|
|
265
|
+
if (tdir / 'merged').exists() or status in ('CONFLICT', 'INVALIDATED'):
|
|
266
|
+
outcome = 'MERGED' if (tdir / 'merged').exists() else status
|
|
267
|
+
events.append(dict(rid='event:merge:legacy:%s:%s' % (uid, tdir.name), kind='event', src='import',
|
|
268
|
+
ts=(tdir / ('merged' if outcome == 'MERGED' else 'status')).stat().st_mtime,
|
|
269
|
+
run=uid, run_id=prov['run_id'], mode='parallel', task=tdir.name,
|
|
270
|
+
data=dict(name='merge', status=outcome, legacy=True,
|
|
271
|
+
reviewed_commit=h.first_line(tdir / 'reviewed.commit'))))
|
|
272
|
+
for review in (sorted(plan.glob('final-review-*')) + sorted(plan.glob('integrate-*/final-review'))) if not live else []:
|
|
273
|
+
if not review.is_dir():
|
|
274
|
+
continue
|
|
275
|
+
events.append(dict(rid='event:final_review:legacy:%s:%s' % (uid, sha256(str(mapper(review)).encode())[:16]),
|
|
276
|
+
kind='event', src='import', ts=review.stat().st_mtime, run=uid,
|
|
277
|
+
run_id=prov['run_id'], mode='parallel',
|
|
278
|
+
data=dict(name='final_review', legacy=True, dir=str(mapper(review)),
|
|
279
|
+
verdict=h.first_line(review / 'verdict'), meta=h.kv(review / 'review.meta') or None,
|
|
280
|
+
blobs=h.blobs(diff=put_file(review / 'combined.diff', 'diff')))))
|
|
281
|
+
batches.append((events, repo))
|
|
282
|
+
return batches
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def ledger_events(ledger: Path, repo: str) -> list[dict]:
|
|
286
|
+
events = []
|
|
287
|
+
key = repo_id(repo)
|
|
288
|
+
columns = ('ts', 'run_id', 'task', 'role', 'model', 'verdict', 'category', 'key', 'text', 'duration_s', 'injectable')
|
|
289
|
+
for line in (h.text(ledger) or '').splitlines():
|
|
290
|
+
if not line.strip() or line.startswith('#'):
|
|
291
|
+
continue
|
|
292
|
+
cells = line.split('\t')
|
|
293
|
+
row = {name: (cells[i] if i < len(cells) else None) for i, name in enumerate(columns)}
|
|
294
|
+
try:
|
|
295
|
+
ts = time.mktime(time.strptime(row['ts'], '%Y-%m-%dT%H:%M:%SZ')) - time.timezone
|
|
296
|
+
except (TypeError, ValueError):
|
|
297
|
+
ts = 0
|
|
298
|
+
events.append(dict(rid='ledger:%s:%s' % (key, sha256(line.encode())[:24]), kind='ledger', src='import',
|
|
299
|
+
ts=ts, run_id=row['run_id'], task=None if row['task'] in (None, '-', '') else row['task'],
|
|
300
|
+
data=row))
|
|
301
|
+
return events
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _manifest(directory: Path) -> dict:
|
|
305
|
+
out = {}
|
|
306
|
+
for line in (h.text(directory / 'MANIFEST.tsv') or '').splitlines():
|
|
307
|
+
cells = line.split('\t')
|
|
308
|
+
if len(cells) >= 2 and not line.startswith('#'):
|
|
309
|
+
out[cells[0]] = cells[1]
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _member(member, path):
|
|
314
|
+
# Links are never run data (Fractal's archive carries a venv full of absolute ones),
|
|
315
|
+
# and skipping them means nothing extracted can point outside the temp dir.
|
|
316
|
+
if member.issym() or member.islnk():
|
|
317
|
+
return None
|
|
318
|
+
return tarfile.data_filter(member, path)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _extract(archive: Path, into: Path) -> None:
|
|
322
|
+
with tarfile.open(archive, 'r:gz') as bundle:
|
|
323
|
+
if hasattr(tarfile, 'data_filter'):
|
|
324
|
+
bundle.extractall(into, filter=_member)
|
|
325
|
+
else:
|
|
326
|
+
members = []
|
|
327
|
+
for member in bundle.getmembers():
|
|
328
|
+
target = (into / member.name).resolve()
|
|
329
|
+
if member.issym() or member.islnk():
|
|
330
|
+
continue
|
|
331
|
+
if not str(target).startswith(str(into.resolve()) + os.sep):
|
|
332
|
+
raise ValueError('unsafe path in archive: ' + member.name)
|
|
333
|
+
members.append(member)
|
|
334
|
+
bundle.extractall(into, members=members)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def main(args) -> int:
|
|
338
|
+
from . import harvest # noqa: F401 (keeps the import graph explicit)
|
|
339
|
+
import forge_dataset as store
|
|
340
|
+
store.DRY = bool(args.dry_run)
|
|
341
|
+
counts = {'events': 0, 'new': 0}
|
|
342
|
+
by_kind: dict = {}
|
|
343
|
+
targets = [Path(p).expanduser() for p in args.paths]
|
|
344
|
+
if args.registry:
|
|
345
|
+
for registry in sorted(config_runs_dir().glob('*.txt')):
|
|
346
|
+
for line in (h.text(registry) or '').splitlines():
|
|
347
|
+
if line.strip() and not line.startswith('#') and Path(line.strip()).is_dir():
|
|
348
|
+
targets.append(Path(line.strip()))
|
|
349
|
+
if args.fractal:
|
|
350
|
+
state = Path(os.environ.get('XDG_STATE_HOME') or str(Path.home() / '.local' / 'state')).expanduser()
|
|
351
|
+
targets.append(state / 'forge' / 'fractal' / 'runs')
|
|
352
|
+
ledgers = [(Path(r).expanduser() / '.forge' / 'ledger.tsv', str(Path(r).expanduser().resolve())) for r in args.ledger]
|
|
353
|
+
archives = []
|
|
354
|
+
for target in list(targets):
|
|
355
|
+
if target.is_dir() and (target / 'MANIFEST.tsv').exists():
|
|
356
|
+
targets.remove(target)
|
|
357
|
+
manifest = _manifest(target)
|
|
358
|
+
archives += [(target / name, Path(source)) for name, source in manifest.items() if (target / name).exists()]
|
|
359
|
+
repos = target / 'ledgers' / 'REPOS.tsv'
|
|
360
|
+
for line in (h.text(repos) or '').splitlines():
|
|
361
|
+
cells = line.split('\t')
|
|
362
|
+
if len(cells) == 2 and (target / 'ledgers' / (cells[0] + '.ledger.tsv')).exists():
|
|
363
|
+
ledgers.append((target / 'ledgers' / (cells[0] + '.ledger.tsv'), cells[1]))
|
|
364
|
+
elif target.is_file() and target.name.endswith(('.tar.gz', '.tgz')):
|
|
365
|
+
targets.remove(target)
|
|
366
|
+
archives.append((target, None))
|
|
367
|
+
|
|
368
|
+
def write(batches):
|
|
369
|
+
for events, repo in batches:
|
|
370
|
+
if not events:
|
|
371
|
+
continue
|
|
372
|
+
counts['events'] += len(events)
|
|
373
|
+
for event in events:
|
|
374
|
+
by_kind[event['kind']] = by_kind.get(event['kind'], 0) + 1
|
|
375
|
+
if not args.dry_run:
|
|
376
|
+
counts['new'] += record(events, repo=repo, where='import')
|
|
377
|
+
|
|
378
|
+
for target in targets:
|
|
379
|
+
if not target.exists():
|
|
380
|
+
print('forge dataset: skipping %s (missing)' % target)
|
|
381
|
+
continue
|
|
382
|
+
write(import_tree(target, Mapper(), args, counts))
|
|
383
|
+
for archive, source in archives:
|
|
384
|
+
work = Path(tempfile.mkdtemp(prefix='forge-dataset-import-'))
|
|
385
|
+
try:
|
|
386
|
+
_extract(archive, work)
|
|
387
|
+
mapper = Mapper()
|
|
388
|
+
if source is not None:
|
|
389
|
+
mapper.add(work, source.parent)
|
|
390
|
+
write(import_tree(work, mapper, args, counts))
|
|
391
|
+
except Exception as error:
|
|
392
|
+
counts.setdefault('failed', []).append('%s: %s' % (archive, error))
|
|
393
|
+
finally:
|
|
394
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
395
|
+
for ledger, repo in ledgers:
|
|
396
|
+
if ledger.exists() and (args.include_temp or not is_temp(repo)):
|
|
397
|
+
write([(ledger_events(ledger, repo), repo)])
|
|
398
|
+
verb = 'would import' if args.dry_run else 'imported'
|
|
399
|
+
print('forge dataset: %s %d event(s)%s from %d dir(s), %d archive(s), %d ledger(s)'
|
|
400
|
+
% (verb, counts['events'], '' if args.dry_run else ' (%d new)' % counts['new'],
|
|
401
|
+
len(targets), len(archives), len(ledgers)))
|
|
402
|
+
if by_kind:
|
|
403
|
+
print(' ' + ', '.join('%s %d' % kv for kv in sorted(by_kind.items())))
|
|
404
|
+
if counts.get('skipped_unfinished'):
|
|
405
|
+
print(' skipped %d unfinished execution(s): still running or killed; live capture records them when they end'
|
|
406
|
+
% counts['skipped_unfinished'])
|
|
407
|
+
if counts.get('skipped_temp'):
|
|
408
|
+
print(' skipped %d execution(s) whose repo is a temp dir (test runs; --include-temp to keep them)'
|
|
409
|
+
% counts['skipped_temp'])
|
|
410
|
+
for failure in counts.get('failed', [])[:20]:
|
|
411
|
+
print(' failed: ' + failure)
|
|
412
|
+
return 1 if counts.get('failed') else 0
|