@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,330 @@
1
+ """Live capture: called from forge-runtime.py's begin()/finish() and from point events.
2
+
3
+ Every function here is an entry point a running pipeline calls, so every one of them
4
+ is fail-open: errors go to errors.log, nothing is printed (begin()'s stdout is the
5
+ attempt path the shell captures), and nothing is raised.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from . import PROVENANCE, enabled, location_refused, log_error, provenance, put_blob, put_file, record
15
+ from . import harvest as h
16
+
17
+
18
+ def _environment() -> dict:
19
+ return dict(parent=os.environ.get('FORGE_DATASET_PARENT') or None,
20
+ inv=os.environ.get('FORGE_DATASET_INVOCATION') or None,
21
+ trigger=os.environ.get('FORGE_DATASET_TRIGGER') or None)
22
+
23
+
24
+ def on_begin(attempt: str, root: str, role: str) -> None:
25
+ """Write the execution's provenance into its immutable attempt dir and a start receipt.
26
+
27
+ The provenance travels with the attempt dir, so a plan split or combined later (which
28
+ copies attempt dirs unchanged) still names the run the execution really belonged to.
29
+ """
30
+ if not enabled():
31
+ return
32
+ try:
33
+ attempt_dir, root_dir = Path(attempt), Path(root)
34
+ ctx = h.locate(root_dir, role)
35
+ if location_refused(ctx['repo']):
36
+ return
37
+ uid = h.run_uid(ctx['run_dir'], create=True)
38
+ env = _environment()
39
+ if role == 'task' and not env['trigger']:
40
+ env['trigger'] = 'initial'
41
+ # The code is fingerprinted here, when the execution starts: the prompt is built
42
+ # from it now, and finish() runs in another process that may see a later edit.
43
+ prov = dict(
44
+ schema=1, run=uid, run_id=ctx['run_id'], mode=ctx['mode'], scope=ctx['scope'],
45
+ plan=str(ctx['plan']) if ctx['plan'] else None, task=ctx['task'], repo=ctx['repo'],
46
+ link=ctx['link'], role=role, root=str(root_dir), wall_started_ns=time.time_ns(),
47
+ provenance=provenance(), **env,
48
+ )
49
+ if os.environ.get('FORGE_SHADOW') == '1':
50
+ # Carried in the attempt dir, so an import long after still knows what this was.
51
+ prov['shadow'] = dict(attempt=h.as_int(os.environ.get('FORGE_SHADOW_ATTEMPT')),
52
+ reviewed=os.environ.get('FORGE_SHADOW_REVIEWED') or None,
53
+ primary=os.environ.get('FORGE_SHADOW_FROM') or None)
54
+ if role in h.PIPELINE_ROLES:
55
+ prov['files'] = h.snapshot(root_dir)
56
+ if role == 'task':
57
+ prov['attempt_start'] = h.as_int(h.first_line(root_dir / 'attempt')) or 0
58
+ prov['resume'] = h.first_line(root_dir / 'resume') or None
59
+ h.write_json(attempt_dir / 'dataset.json', prov)
60
+ record([dict(rid=attempt_dir.name + ':start', kind='start', ts=time.time(), run=uid,
61
+ run_id=ctx['run_id'], mode=ctx['mode'], task=ctx['task'], parent=env['parent'],
62
+ inv=env['inv'], data=dict(role=role, root=str(root_dir), scope=ctx['scope'],
63
+ trigger=env['trigger'], link=ctx['link']))],
64
+ repo=ctx['repo'], where='begin')
65
+ except Exception as error:
66
+ log_error('begin', error)
67
+
68
+
69
+ def provenance_for(attempt_dir: Path, root_dir: Path, role: str) -> dict:
70
+ """The provenance begin() wrote, or one reconstructed for an attempt dir without it."""
71
+ prov = h.read_json(attempt_dir / 'dataset.json')
72
+ if prov is None:
73
+ ctx = h.locate(root_dir, role)
74
+ prov = dict(run=h.run_uid(ctx['run_dir'], create=False), run_id=ctx['run_id'], mode=ctx['mode'],
75
+ scope=ctx['scope'], plan=str(ctx['plan']) if ctx['plan'] else None, task=ctx['task'],
76
+ repo=ctx['repo'], link=ctx['link'], files=None)
77
+ return prov
78
+
79
+
80
+ def events_for(attempt_dir: Path, root_dir: Path, role: str, harness: str, rc, prov: dict, src: str) -> list:
81
+ if harness != 'pipeline':
82
+ if prov.get('shadow'):
83
+ prov['attempt_now'] = prov['shadow'].get('attempt') # its dir has no attempt counter
84
+ elif prov.get('scope') == 'task':
85
+ prov['attempt_now'] = h.as_int(h.first_line(root_dir / 'attempt'))
86
+ return [h.dispatch_event(attempt_dir, root_dir, role, harness, rc, prov, src)]
87
+ if role == 'task':
88
+ return [h.task_event(attempt_dir, root_dir, rc, prov, src)]
89
+ if role == 'solo':
90
+ return [h.solo_event(attempt_dir, root_dir, rc, prov, src)]
91
+ if role == 'verification':
92
+ return h.verification_events(attempt_dir, root_dir, rc, prov, src)
93
+ if role == 'parallel':
94
+ return [h.run_end_event(attempt_dir, root_dir, rc, prov, src)]
95
+ return []
96
+
97
+
98
+ def stamp(events: list, started: dict | None, check_change: bool = True) -> None:
99
+ """Attribute an execution's events to the code it started with.
100
+
101
+ A live edit between begin and finish would otherwise credit the outcome to code the
102
+ execution never ran; the change itself is kept, so a mixed execution can be dropped.
103
+ An import has no finish-time code to compare with, so it only keeps what begin saw.
104
+ """
105
+ if not started:
106
+ return
107
+ changed = check_change and started.get('code_sha') != provenance().get('code_sha')
108
+ for event in events:
109
+ for key in PROVENANCE:
110
+ event.setdefault(key, started.get(key))
111
+ if changed:
112
+ event.setdefault('data', {})['code_changed'] = True
113
+
114
+
115
+ def on_finish(attempt: str, root: str, role: str, harness: str, rc) -> None:
116
+ if not enabled():
117
+ return
118
+ try:
119
+ attempt_dir, root_dir = Path(attempt), Path(root)
120
+ prov = provenance_for(attempt_dir, root_dir, role)
121
+ if location_refused(prov.get('repo')):
122
+ return
123
+ prov['wall_ended'] = time.time()
124
+ events = events_for(attempt_dir, root_dir, role, harness, rc, prov, 'live')
125
+ stamp(events, prov.get('provenance'))
126
+ record(events, repo=prov.get('repo'), where='finish')
127
+ except Exception as error:
128
+ log_error('finish', error)
129
+
130
+
131
+ def point(name: str, directory: str | None, task: str | None = None, values: dict | None = None,
132
+ files: dict | None = None, kind: str = 'event', texts: dict | None = None,
133
+ repo: str | None = None) -> None:
134
+ """A fact that would be gone by the time any EXIT trap could read it.
135
+
136
+ Merge conflicts are aborted, combine rolls back, integrate removes its candidate
137
+ worktree; this records each one at the moment it is still on disk.
138
+ """
139
+ if not enabled():
140
+ return
141
+ try:
142
+ env = _environment()
143
+ ctx = h.locate(Path(directory)) if directory else dict(run_dir=None, run_id=None, mode=None, repo=None)
144
+ if location_refused(repo or ctx.get('repo')):
145
+ return
146
+ uid = h.run_uid(ctx['run_dir'], create=False) if ctx.get('run_dir') else None
147
+ data = dict(values or {}, name=name)
148
+ refs = {}
149
+ for label, path in (files or {}).items():
150
+ ref = put_file(path, 'diff' if path.endswith('.diff') else 'text')
151
+ if ref:
152
+ refs[label] = ref
153
+ for label, value in (texts or {}).items():
154
+ if value:
155
+ refs[label] = put_blob(value, 'log')
156
+ task = task or ctx.get('task')
157
+ data['blobs'] = refs
158
+ rid = 'event:%s:%s' % (name, h.sha256(json.dumps([uid, task, time.time_ns(), data], sort_keys=True,
159
+ default=str).encode())[:24])
160
+ record([dict(rid=rid, kind=kind, ts=time.time(), run=uid, run_id=ctx.get('run_id'),
161
+ mode=ctx.get('mode'), task=task, parent=env['parent'], inv=env['inv'], data=data)],
162
+ repo=repo or ctx.get('repo'), where='event:' + name)
163
+ except Exception as error:
164
+ log_error('event:' + name, error)
165
+
166
+
167
+ def mint_run_uid(run_dir) -> None:
168
+ """Create the run dir's dataset uid now, if capture is on. Write-only, fail-open.
169
+
170
+ For decisions recorded before the run's own first capture would mint it (a plan's
171
+ predictions run inside `plan`): without it each would carry a legacy id that never
172
+ joins the attempts the plan goes on to run.
173
+ """
174
+ if not enabled():
175
+ return
176
+ try:
177
+ h.run_uid(Path(run_dir), create=True)
178
+ except Exception as error:
179
+ log_error('mint_run_uid', error)
180
+
181
+
182
+ def jev(*, site, key, canonical: bytes, ok, reason, latency, model, usage, questions, answers,
183
+ run_dir, acting, shadow, extra=None) -> None:
184
+ """One Jev request: the full state and questions it judged, and what came back.
185
+
186
+ The request blob's original sha equals jev.jsonl's `key`, so a run dir that still
187
+ exists joins to it, and a judgment can later be replayed against any other model.
188
+ `extra` carries the provider, the occurrence id (a body asked twice is two
189
+ decisions), chunk hashes, and for plan-level sites the task they were about.
190
+ """
191
+ if not enabled():
192
+ return
193
+ try:
194
+ env = _environment()
195
+ ctx = h.locate(Path(run_dir)) if run_dir else dict(run_dir=None, run_id=None, mode=None, task=None, repo=None)
196
+ if location_refused(ctx.get('repo')):
197
+ return
198
+ uid = h.run_uid(ctx['run_dir'], create=False) if ctx.get('run_dir') else None
199
+ at = time.time()
200
+ rid = 'jev:' + h.sha256(('%s\0%r\0%d\0%d' % (key, at, os.getpid(), time.perf_counter_ns())).encode())[:24]
201
+ data = dict(site=site, key=key, ok=ok, reason=reason, latency_s=latency, model=model, usage=usage,
202
+ questions=questions, answers=answers, acting=acting, shadow=shadow,
203
+ run_dir=str(run_dir) if run_dir else None, blobs=dict(request=put_blob(canonical, 'request')))
204
+ data.update(extra or {})
205
+ record([dict(rid=rid, kind='jev', ts=at, run=uid, run_id=ctx.get('run_id'), mode=ctx.get('mode'),
206
+ task=ctx.get('task') or (extra or {}).get('task'), parent=env['parent'], inv=env['inv'],
207
+ data=data)],
208
+ repo=ctx.get('repo'), where='jev:' + site)
209
+ except Exception as error:
210
+ log_error('jev:' + str(site), error)
211
+
212
+
213
+ def fractal_step(task_dir, node: dict, step_dir, *, phase: str, iteration, rc, cost, resolved) -> None:
214
+ """One Fractal step, keyed by its own step uuid.
215
+
216
+ node.json is mutable (a retry resets `iteration` to 0), so a step is recorded by
217
+ the uuid of the dir it ran in rather than by (node, iteration), which can repeat.
218
+ Called from worker threads: no signals, a connection per call.
219
+ """
220
+ if not enabled():
221
+ return
222
+ try:
223
+ task_dir, step_dir = Path(task_dir), Path(step_dir)
224
+ ctx = h.fractal_context(task_dir, create=True)
225
+ if location_refused(ctx['repo']):
226
+ return
227
+ record([h.fractal_step_event(task_dir, node, step_dir, ctx, phase=phase, iteration=iteration, rc=rc,
228
+ cost=cost, resolved=resolved)], repo=ctx['repo'], where='fractal_step')
229
+ except Exception as error:
230
+ log_error('fractal_step', error)
231
+
232
+
233
+ def fractal_outcome(task_dir) -> None:
234
+ """Every node's final state and the task outcome, once per distinct state."""
235
+ if not enabled():
236
+ return
237
+ try:
238
+ task_dir = Path(task_dir)
239
+ ctx = h.fractal_context(task_dir, create=True)
240
+ if location_refused(ctx['repo']):
241
+ return
242
+ record(h.fractal_task_events(task_dir, ctx), repo=ctx['repo'], where='fractal_outcome')
243
+ except Exception as error:
244
+ log_error('fractal_outcome', error)
245
+
246
+
247
+ def plan_snapshot(plan: str) -> None:
248
+ """Every routing, decomposition and scheduling decision made before the first dispatch."""
249
+ if not enabled():
250
+ return
251
+ try:
252
+ plan_dir = Path(plan)
253
+ if not (plan_dir / 'tasks.tsv').exists():
254
+ return
255
+ ctx = h.locate(plan_dir)
256
+ if location_refused(ctx['repo']):
257
+ return
258
+ prov = dict(run=h.run_uid(plan_dir, create=True), run_id=ctx['run_id'],
259
+ inv=_environment()['inv'])
260
+ record([h.plan_event(plan_dir, prov)], repo=ctx['repo'], where='plan')
261
+ except Exception as error:
262
+ log_error('plan', error)
263
+
264
+
265
+ def invocation(command: str, directory: str | None, rc, started: float | None, argv: list,
266
+ pid: str | None = None) -> None:
267
+ """One forge-parallel.sh subcommand: what was asked, how it ended, and what it left.
268
+
269
+ Artifacts a subcommand writes into a dir named after its own pid (final-review-*-PID,
270
+ integrate-*-PID) are read here, after they exist and before anything deletes them.
271
+ """
272
+ if not enabled():
273
+ return
274
+ try:
275
+ env = _environment()
276
+ plan = Path(directory) if directory and Path(directory).is_dir() else None
277
+ ctx = h.locate(plan) if plan else dict(run_dir=None, run_id=None, mode=None, repo=None)
278
+ if location_refused(ctx.get('repo')):
279
+ return
280
+ # `plan` is what makes a plan dir a run, so it may mint the id; every other command
281
+ # finds the one plan (or a dispatch's begin) already wrote.
282
+ uid = h.run_uid(plan, create=command == 'plan') if plan and (plan / 'tasks.tsv').exists() else None
283
+ data = dict(command=command, argv=argv, exit=h.as_int(rc), plan=str(plan) if plan else None,
284
+ duration_s=round(time.time() - float(started), 3) if started else None)
285
+ refs = {}
286
+ if plan and pid:
287
+ produced = []
288
+ for path in sorted(plan.glob('*-%s' % pid)):
289
+ if not path.is_dir():
290
+ continue
291
+ entry = dict(dir=path.name)
292
+ for name in ('verdict', 'verification.status', 'verification.exit', 'verification.command'):
293
+ value = h.first_line(path / name)
294
+ if value is not None:
295
+ entry[name] = value
296
+ inner = path / 'final-review'
297
+ if inner.is_dir():
298
+ entry['final_review_verdict'] = h.first_line(inner / 'verdict')
299
+ entry['final_review_meta'] = h.kv(inner / 'review.meta') or None
300
+ if (path / 'review.meta').exists():
301
+ entry['review_meta'] = h.kv(path / 'review.meta')
302
+ ref = put_file(path / 'qa.last', 'message')
303
+ if ref:
304
+ refs['final_review_message'] = ref
305
+ ref = put_file(path / 'combined.diff', 'diff')
306
+ if ref:
307
+ refs['final_review_diff'] = ref
308
+ produced.append(entry)
309
+ data['produced'] = produced
310
+ data['final_review_verdict'] = h.first_line(plan / 'final-review.verdict')
311
+ if command == 'accept' and plan and len(argv) > 0:
312
+ task = next((a for a in argv if not a.startswith('-') and (plan / 'tasks' / a).is_dir()), None)
313
+ if task:
314
+ data['task'] = task
315
+ data['accepted_by'] = h.text(plan / 'tasks' / task / 'accepted.by')
316
+ data['status_after'] = h.first_line(plan / 'tasks' / task / 'status')
317
+ ref = put_file(plan / 'tasks' / task / 'known_issues.md')
318
+ if ref:
319
+ refs['known_issues'] = ref
320
+ if command == 'retry' and plan and len(argv) > 0:
321
+ data['task'] = next((a for a in argv if (plan / 'tasks' / a).is_dir()), None)
322
+ data['blobs'] = refs
323
+ record([dict(rid='invocation:%s' % (env['inv'] or h.sha256(repr((command, argv, started)).encode())[:24]),
324
+ kind='invocation', ts=time.time(), run=uid, run_id=ctx.get('run_id'), mode=ctx.get('mode'),
325
+ task=data.get('task'), inv=env['inv'], data=data)],
326
+ repo=ctx.get('repo'), where='invocation:' + command)
327
+ if command == 'plan' and plan:
328
+ plan_snapshot(str(plan))
329
+ except Exception as error:
330
+ log_error('invocation:' + command, error)
@@ -0,0 +1,109 @@
1
+ """Completeness checks for `forge dataset status`.
2
+
3
+ Each check is specific to a mode, because each mode legitimately lacks different
4
+ things: a solo run has no separate verification, and a final review's QA dispatch has
5
+ no task attempt. A check reports what it expected and did not find. It never repairs
6
+ anything, since the run dir that could fill the gap is usually gone.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from . import dataset_dir
16
+
17
+ RUNNING_GRACE_S = 6 * 3600 # a start this recent may simply still be running
18
+
19
+
20
+ def _events(db: sqlite3.Connection, where: str = '', params: tuple = ()) -> list[dict]:
21
+ cursor = db.execute('SELECT rid, kind, ts, src, run, mode, task, parent, data FROM events %s' % where, params)
22
+ names = [d[0] for d in cursor.description]
23
+ out = []
24
+ for row in cursor:
25
+ event = dict(zip(names, row))
26
+ event['data'] = json.loads(event['data'])
27
+ out.append(event)
28
+ return out
29
+
30
+
31
+ def _refs(value) -> list[str]:
32
+ found = []
33
+ if isinstance(value, dict):
34
+ if set(value) >= {'sha', 'n', 'kept'} and isinstance(value.get('sha'), str):
35
+ found.append(value['sha'])
36
+ for item in value.values():
37
+ found.extend(_refs(item))
38
+ elif isinstance(value, list):
39
+ for item in value:
40
+ found.extend(_refs(item))
41
+ return found
42
+
43
+
44
+ def run(db: sqlite3.Connection, verbose: bool = False) -> dict:
45
+ now = time.time()
46
+ events = _events(db)
47
+ by_rid = {e['rid'] for e in events}
48
+ results = {}
49
+
50
+ unfinished = [e for e in events if e['kind'] == 'start' and e['rid'][:-len(':start')] not in by_rid]
51
+ results['incomplete'] = dict(
52
+ what='executions that started but never recorded an end (killed, or still running)',
53
+ items=sorted(e['rid'][:-len(':start')] for e in unfinished if now - e['ts'] > RUNNING_GRACE_S),
54
+ running=sum(1 for e in unfinished if now - e['ts'] <= RUNNING_GRACE_S))
55
+
56
+ attempts = {e['rid'] for e in events if e['kind'] == 'attempt'}
57
+ orphan_qa = [e['rid'] for e in events if e['kind'] == 'dispatch' and e['data'].get('role') == 'qa'
58
+ and e['data'].get('scope') == 'task' and e['src'] == 'live'
59
+ and e.get('parent') and e['parent'] not in attempts and now - e['ts'] > RUNNING_GRACE_S]
60
+ results['qa_without_attempt'] = dict(what='task QA dispatches whose task attempt was never recorded',
61
+ items=sorted(orphan_qa))
62
+
63
+ ended = {e['run'] for e in events if e['kind'] == 'run_end'}
64
+ merges = {(e['run'], e['task']) for e in events if e['kind'] == 'event' and e['data'].get('name') == 'merge'}
65
+ unmerged = sorted('%s/%s' % (e['run'], e['task']) for e in events
66
+ if e['kind'] == 'attempt' and e['mode'] == 'parallel' and e['src'] == 'live'
67
+ and e['data'].get('status') == 'PASS' and e['run'] in ended
68
+ and (e['run'], e['task']) not in merges)
69
+ results['pass_without_merge'] = dict(what='parallel PASS attempts with no merge outcome in a finished run',
70
+ items=unmerged)
71
+
72
+ last_seen = {}
73
+ for e in events:
74
+ if e['mode'] == 'parallel' and e['kind'] == 'attempt' and e['src'] == 'live':
75
+ last_seen[e['run']] = max(last_seen.get(e['run'], 0), e['ts'])
76
+ results['run_without_end'] = dict(what='parallel runs with task attempts but no run end',
77
+ items=sorted(r for r, ts in last_seen.items()
78
+ if r not in ended and now - ts > RUNNING_GRACE_S))
79
+
80
+ blobs = dataset_dir() / 'blobs'
81
+ missing = sorted({sha for e in events for sha in _refs(e['data'])
82
+ if not (blobs / sha[:2] / (sha + '.gz')).exists()})
83
+ results['missing_blobs'] = dict(what='blob references with no stored blob', items=missing)
84
+
85
+ if not verbose:
86
+ for check in results.values():
87
+ check['count'] = len(check['items'])
88
+ check['items'] = check['items'][:5]
89
+ else:
90
+ for check in results.values():
91
+ check['count'] = len(check['items'])
92
+ return results
93
+
94
+
95
+ def report(results: dict) -> list[str]:
96
+ lines = []
97
+ problems = {name: c for name, c in results.items() if c['count']}
98
+ running = results.get('incomplete', {}).get('running')
99
+ if not problems:
100
+ lines.append('checks: complete' + (' (%d execution(s) still running)' % running if running else ''))
101
+ return lines
102
+ lines.append('checks: %d issue(s)' % sum(c['count'] for c in problems.values()))
103
+ for name, check in problems.items():
104
+ lines.append(' %s: %d — %s' % (name, check['count'], check['what']))
105
+ for item in check['items']:
106
+ lines.append(' %s' % item)
107
+ if check['count'] > len(check['items']):
108
+ lines.append(' … (--verbose lists all)')
109
+ return lines
@@ -0,0 +1,233 @@
1
+ """`forge dataset ...`: inspect the run dataset, import old runs, and the shell's capture hooks.
2
+
3
+ User-facing: status, dump, import, export, outcomes. Internal (called by forge's shell scripts, fail-open,
4
+ silent): event, invocation, plan.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from . import connect, data_home, dataset_dir, enabled, read_blob
15
+
16
+
17
+ def _size(path: Path) -> int:
18
+ if path.is_file():
19
+ return path.stat().st_size
20
+ return sum(p.stat().st_size for p in path.rglob('*') if p.is_file()) if path.is_dir() else 0
21
+
22
+
23
+ def _human(n: int) -> str:
24
+ for unit in ('B', 'KB', 'MB', 'GB'):
25
+ if n < 1024 or unit == 'GB':
26
+ return ('%d %s' % (n, unit)) if unit == 'B' else ('%.1f %s' % (n, unit))
27
+ n /= 1024.0
28
+ return str(n)
29
+
30
+
31
+ def status(args) -> int:
32
+ from . import checks
33
+ directory = dataset_dir()
34
+ summary = dict(path=str(directory), enabled=enabled(), exists=(directory / 'dataset.sqlite').exists())
35
+ if summary['exists']:
36
+ db = connect()
37
+ try:
38
+ summary['events'] = dict(db.execute('SELECT kind, COUNT(*) FROM events GROUP BY kind ORDER BY kind'))
39
+ summary['by_source'] = dict(db.execute('SELECT src, COUNT(*) FROM events GROUP BY src'))
40
+ summary['repos'] = [dict(repo=r, path=p, origin=o) for r, p, o in
41
+ db.execute('SELECT repo, path, origin FROM repos ORDER BY path')]
42
+ summary['runs'] = db.execute('SELECT COUNT(DISTINCT run) FROM events WHERE run IS NOT NULL').fetchone()[0]
43
+ last = db.execute('SELECT MAX(ts) FROM events').fetchone()[0]
44
+ summary['last_event'] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(last)) if last else None
45
+ summary['checks'] = checks.run(db, verbose=args.verbose)
46
+ if summary['events'].get('dispatch'):
47
+ from . import export
48
+ if db.execute("SELECT 1 FROM events WHERE kind = 'dispatch' AND data LIKE '%qa_shadow%' LIMIT 1").fetchone():
49
+ summary['shadow'] = export.agreement(export.Runs(export.Store(db)))
50
+ finally:
51
+ db.close()
52
+ summary['size'] = dict(database=_size(directory / 'dataset.sqlite') + _size(directory / 'dataset.sqlite-wal'),
53
+ blobs=_size(directory / 'blobs'))
54
+ errors = directory / 'errors.log'
55
+ summary['errors'] = len(errors.read_text(errors='replace').splitlines()) if errors.exists() else 0
56
+ legacy = data_home() / 'legacy'
57
+ summary['legacy_archives'] = len(list(legacy.glob('*.tar.gz'))) if legacy.is_dir() else 0
58
+ if args.json:
59
+ print(json.dumps(summary, indent=2, sort_keys=True))
60
+ return 0
61
+ print('forge dataset: %s (%s)' % (summary['path'], 'capturing' if summary['enabled'] else 'off: FORGE_DATASET=off'))
62
+ if not summary['exists']:
63
+ print(' nothing captured yet')
64
+ else:
65
+ print(' size: database %s, blobs %s' % (_human(summary['size']['database']), _human(summary['size']['blobs'])))
66
+ print(' events: %d in %d run(s) across %d repo(s); last %s'
67
+ % (sum(summary['events'].values()), summary['runs'], len(summary['repos']), summary['last_event']))
68
+ print(' ' + ', '.join('%s %d' % kv for kv in summary['events'].items()))
69
+ if summary['by_source'].get('import'):
70
+ print(' %d imported from old run dirs' % summary['by_source']['import'])
71
+ for line in checks.report(summary['checks']):
72
+ print(' ' + line)
73
+ shadow = summary.get('shadow')
74
+ if shadow:
75
+ rate = ' (%.0f%% agree)' % (100.0 * shadow['agree'] / shadow['pairs']) if shadow['pairs'] else ''
76
+ print(' shadow: %d second review(s), %d usable pair(s), %d agree%s'
77
+ % (shadow['shadow_reviews'], shadow['pairs'], shadow['agree'], rate))
78
+ for pair, n in shadow['by_pair'].items():
79
+ print(' %s: %d of %d agree' % (pair, n['agree'], n['pairs']))
80
+ if summary['legacy_archives']:
81
+ print(' legacy: %d archived run dir(s) kept in %s (`forge dataset import %s` re-reads them;'
82
+ ' events already stored are skipped)' % (summary['legacy_archives'], legacy, legacy))
83
+ if summary['errors']:
84
+ print(' errors: %d capture failure(s) logged in %s' % (summary['errors'], errors))
85
+ return 0
86
+
87
+
88
+ def dump(args) -> int:
89
+ if not (dataset_dir() / 'dataset.sqlite').exists():
90
+ return 0
91
+ db = connect()
92
+ try:
93
+ where, params = [], []
94
+ for column in ('kind', 'run', 'repo', 'task', 'src'):
95
+ value = getattr(args, column)
96
+ if value:
97
+ where.append('%s = ?' % column)
98
+ params.append(value)
99
+ if args.since:
100
+ where.append('ts >= ?')
101
+ params.append(args.since)
102
+ query = 'SELECT * FROM events%s ORDER BY ts, rid' % (' WHERE ' + ' AND '.join(where) if where else '')
103
+ cursor = db.execute(query, params)
104
+ names = [d[0] for d in cursor.description]
105
+ for row in cursor:
106
+ event = dict(zip(names, row))
107
+ event['data'] = json.loads(event['data'])
108
+ print(json.dumps(event, sort_keys=True))
109
+ finally:
110
+ db.close()
111
+ return 0
112
+
113
+
114
+ def blob(args) -> int:
115
+ sys.stdout.buffer.write(read_blob(args.sha))
116
+ return 0
117
+
118
+
119
+ def _pairs(items: list | None) -> dict:
120
+ out = {}
121
+ for item in items or []:
122
+ key, _, value = item.partition('=')
123
+ if key:
124
+ out[key] = value
125
+ return out
126
+
127
+
128
+ def event(args) -> int:
129
+ from .capture import point
130
+ point(args.name, args.dir, args.task, _pairs(args.set), _pairs(args.file), repo=args.repo)
131
+ return 0
132
+
133
+
134
+ def invocation(args) -> int:
135
+ from .capture import invocation as record_invocation
136
+ record_invocation(args.command, args.dir, args.rc, args.started, args.argv, args.pid)
137
+ return 0
138
+
139
+
140
+ def plan(args) -> int:
141
+ from .capture import plan_snapshot
142
+ plan_snapshot(args.dir)
143
+ return 0
144
+
145
+
146
+ def run_import(args) -> int:
147
+ from . import importer
148
+ return importer.main(args)
149
+
150
+
151
+ def run_export(args) -> int:
152
+ from . import export
153
+ return export.main(args)
154
+
155
+
156
+ def run_outcomes(args) -> int:
157
+ from . import outcomes
158
+ return outcomes.main(args)
159
+
160
+
161
+ def run_routing(args) -> int:
162
+ from . import routing
163
+ return routing.main(args)
164
+
165
+
166
+ def parser() -> argparse.ArgumentParser:
167
+ top = argparse.ArgumentParser(prog='forge dataset', description='The run dataset: every run\'s inputs and outcomes.')
168
+ sub = top.add_subparsers(dest='command', required=True)
169
+ p = sub.add_parser('status', help='where the dataset is, what it holds, and what looks incomplete')
170
+ p.add_argument('--json', action='store_true')
171
+ p.add_argument('--verbose', '-v', action='store_true', help='list every incomplete item, not just counts')
172
+ p.set_defaults(func=status)
173
+ p = sub.add_parser('dump', help='events as JSON lines')
174
+ for column in ('kind', 'run', 'repo', 'task', 'src'):
175
+ p.add_argument('--' + column)
176
+ p.add_argument('--since', type=float, help='unix time')
177
+ p.set_defaults(func=dump)
178
+ p = sub.add_parser('blob', help='print a stored blob by its sha')
179
+ p.add_argument('sha')
180
+ p.set_defaults(func=blob)
181
+ p = sub.add_parser('import', help='capture run dirs, archives and ledgers that predate live capture')
182
+ p.add_argument('paths', nargs='*', help='run/plan dirs, dirs to scan, or .tar.gz archives')
183
+ p.add_argument('--registry', action='store_true', help='every run dir listed in the Jev runs registry')
184
+ p.add_argument('--fractal', action='store_true', help='the Fractal state dir')
185
+ p.add_argument('--ledger', action='append', default=[], metavar='REPO', help="a repo's .forge/ledger.tsv")
186
+ p.add_argument('--include-temp', action='store_true', help='also import runs whose repo is a temp dir (test runs)')
187
+ p.add_argument('--dry-run', action='store_true')
188
+ p.set_defaults(func=run_import)
189
+ p = sub.add_parser('export', help='labeled decision cases (System One JSONL) derived from outcomes')
190
+ p.add_argument('--out', help='output dir (default: <data>/exports/forge-decisions)')
191
+ p.add_argument('--slice', dest='slices', action='append', choices=('routing', 'retry', 'review', 'drift', 'citation', 'memory'),
192
+ help='only this slice; repeatable (default: all)')
193
+ p.add_argument('--limit', type=int, help='at most this many cases per slice, spread across runs')
194
+ p.add_argument('--seed', type=int, default=7)
195
+ p.set_defaults(func=run_export)
196
+ p = sub.add_parser('outcomes', help='what happened to integrated work afterwards (read-only git)')
197
+ p.add_argument('--horizon-days', type=int, default=14, help='how long after integrate to look (default 14)')
198
+ p.add_argument('--repo', help='only this repo')
199
+ p.add_argument('--include-temp', action='store_true', help='also repos under temp dirs (test runs)')
200
+ p.add_argument('--dry-run', action='store_true', help='report what would be recorded, write nothing')
201
+ p.set_defaults(func=run_outcomes)
202
+ p = sub.add_parser('routing', help='pass rate and runtime per tier and model, and what the --explore draws say')
203
+ p.add_argument('--repo', help='only this repo')
204
+ p.add_argument('--since', type=float, metavar='DAYS', help='only tasks first attempted in the last DAYS days')
205
+ p.add_argument('--json', action='store_true')
206
+ p.set_defaults(func=run_routing)
207
+ p = sub.add_parser('event', help=argparse.SUPPRESS)
208
+ p.add_argument('name')
209
+ p.add_argument('--dir')
210
+ p.add_argument('--task')
211
+ p.add_argument('--repo')
212
+ p.add_argument('--set', action='append')
213
+ p.add_argument('--file', action='append')
214
+ p.set_defaults(func=event)
215
+ p = sub.add_parser('invocation', help=argparse.SUPPRESS)
216
+ p.add_argument('command')
217
+ p.add_argument('--dir')
218
+ p.add_argument('--rc')
219
+ p.add_argument('--started')
220
+ p.add_argument('--pid')
221
+ p.add_argument('argv', nargs=argparse.REMAINDER)
222
+ p.set_defaults(func=invocation)
223
+ p = sub.add_parser('plan', help=argparse.SUPPRESS)
224
+ p.add_argument('dir')
225
+ p.set_defaults(func=plan)
226
+ return top
227
+
228
+
229
+ def main(argv: list[str] | None = None) -> int:
230
+ args = parser().parse_args(argv)
231
+ if getattr(args, 'argv', None) and args.argv[:1] == ['--']:
232
+ args.argv = args.argv[1:]
233
+ return args.func(args)