@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,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