@giamme/forge 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1854 -2
  4. package/SKILL.md +122 -0
  5. package/VERSION +1 -0
  6. package/forge +74 -0
  7. package/package.json +26 -4
  8. package/references/dataset.md +329 -0
  9. package/references/decompose.md +667 -0
  10. package/references/fractal-dashboard.md +21 -0
  11. package/references/fractal.md +307 -0
  12. package/references/harnesses.md +353 -0
  13. package/references/jev.md +865 -0
  14. package/references/memory.md +228 -0
  15. package/references/planning.md +42 -0
  16. package/references/ripwire.md +47 -0
  17. package/references/runtime.md +64 -0
  18. package/references/solo.md +113 -0
  19. package/references/ui.md +112 -0
  20. package/registry.tsv +81 -0
  21. package/scripts/forge-artifact.sh +78 -0
  22. package/scripts/forge-contract.py +430 -0
  23. package/scripts/forge-dataset.py +6 -0
  24. package/scripts/forge-dispatch.sh +665 -0
  25. package/scripts/forge-fail-first.py +195 -0
  26. package/scripts/forge-fractal-dispatch.sh +4 -0
  27. package/scripts/forge-fractal-options.sh +54 -0
  28. package/scripts/forge-fractal.py +6 -0
  29. package/scripts/forge-guard.py +266 -0
  30. package/scripts/forge-install-fractal.sh +4 -0
  31. package/scripts/forge-install-ripwire.sh +52 -0
  32. package/scripts/forge-install.sh +135 -0
  33. package/scripts/forge-jev-options.sh +259 -0
  34. package/scripts/forge-jev.py +6 -0
  35. package/scripts/forge-lib-plans.sh +785 -0
  36. package/scripts/forge-lib-review.sh +494 -0
  37. package/scripts/forge-lib-task.sh +302 -0
  38. package/scripts/forge-lib-verify.sh +30 -0
  39. package/scripts/forge-memory.sh +575 -0
  40. package/scripts/forge-metrics.sh +39 -0
  41. package/scripts/forge-parallel-options.sh +76 -0
  42. package/scripts/forge-parallel.sh +1717 -0
  43. package/scripts/forge-plan-check.py +118 -0
  44. package/scripts/forge-prompt.py +45 -0
  45. package/scripts/forge-ripwire.py +136 -0
  46. package/scripts/forge-route.py +344 -0
  47. package/scripts/forge-runtime.py +533 -0
  48. package/scripts/forge-schedule.py +312 -0
  49. package/scripts/forge-shadow.py +305 -0
  50. package/scripts/forge-solo.sh +347 -0
  51. package/scripts/forge-ui-options.sh +42 -0
  52. package/scripts/forge-ui.py +435 -0
  53. package/scripts/forge-update.sh +71 -0
  54. package/scripts/forge-verify-coverage.py +956 -0
  55. package/scripts/forge-version.py +198 -0
  56. package/scripts/forge_dataset/__init__.py +439 -0
  57. package/scripts/forge_dataset/capture.py +330 -0
  58. package/scripts/forge_dataset/checks.py +109 -0
  59. package/scripts/forge_dataset/cli.py +233 -0
  60. package/scripts/forge_dataset/export.py +1009 -0
  61. package/scripts/forge_dataset/harvest.py +716 -0
  62. package/scripts/forge_dataset/importer.py +412 -0
  63. package/scripts/forge_dataset/outcomes.py +275 -0
  64. package/scripts/forge_dataset/routing.py +278 -0
  65. package/scripts/forge_fractal/__init__.py +83 -0
  66. package/scripts/forge_fractal/bridge.py +46 -0
  67. package/scripts/forge_fractal/cli.py +202 -0
  68. package/scripts/forge_fractal/dashboard.css +138 -0
  69. package/scripts/forge_fractal/dashboard.html +41 -0
  70. package/scripts/forge_fractal/dashboard.js +687 -0
  71. package/scripts/forge_fractal/decomposition.py +262 -0
  72. package/scripts/forge_fractal/execution.py +659 -0
  73. package/scripts/forge_fractal/inspection.py +261 -0
  74. package/scripts/forge_fractal/install.py +158 -0
  75. package/scripts/forge_fractal/invocation.py +94 -0
  76. package/scripts/forge_fractal/pipeline.py +124 -0
  77. package/scripts/forge_fractal/selection.py +179 -0
  78. package/scripts/forge_jev/__init__.py +376 -0
  79. package/scripts/forge_jev/backtest.py +322 -0
  80. package/scripts/forge_jev/cli.py +980 -0
  81. package/scripts/forge_jev/client.py +418 -0
  82. package/scripts/forge_jev/corpus.py +156 -0
  83. package/scripts/forge_jev/coupling.py +244 -0
  84. package/scripts/forge_jev/features.py +24 -0
  85. package/scripts/forge_jev/gates.py +100 -0
  86. package/scripts/forge_jev/memory.py +163 -0
  87. package/scripts/forge_jev/outcome.py +49 -0
  88. package/scripts/forge_jev/predict.py +204 -0
  89. package/scripts/forge_jev/questions.py +661 -0
  90. package/scripts/forge_jev/review.py +73 -0
  91. package/scripts/forge_jev/routing.py +271 -0
  92. package/scripts/forge_jev/subset.py +123 -0
  93. package/scripts/forge_jev/verify.py +453 -0
  94. package/scripts/forge_registry.py +140 -0
  95. package/ui/README.md +287 -0
  96. package/ui/THIRD_PARTY_NOTICES.md +69 -0
  97. package/ui/docs/api.md +888 -0
  98. package/ui/forge_ui/__init__.py +20 -0
  99. package/ui/forge_ui/__main__.py +135 -0
  100. package/ui/forge_ui/diffs.py +454 -0
  101. package/ui/forge_ui/executions.py +436 -0
  102. package/ui/forge_ui/export.py +204 -0
  103. package/ui/forge_ui/findings.py +132 -0
  104. package/ui/forge_ui/fractal.py +583 -0
  105. package/ui/forge_ui/models.py +216 -0
  106. package/ui/forge_ui/reader.py +1336 -0
  107. package/ui/forge_ui/server.py +303 -0
  108. package/ui/forge_ui/static/app.js +924 -0
  109. package/ui/forge_ui/static/cloud3d.js +646 -0
  110. package/ui/forge_ui/static/cloudmodel.js +375 -0
  111. package/ui/forge_ui/static/cloudui.js +394 -0
  112. package/ui/forge_ui/static/diffview.js +216 -0
  113. package/ui/forge_ui/static/favicon.svg +1 -0
  114. package/ui/forge_ui/static/files.js +776 -0
  115. package/ui/forge_ui/static/gantt.js +97 -0
  116. package/ui/forge_ui/static/graph.js +266 -0
  117. package/ui/forge_ui/static/index.html +55 -0
  118. package/ui/forge_ui/static/iso.js +765 -0
  119. package/ui/forge_ui/static/leaderboard.js +315 -0
  120. package/ui/forge_ui/static/map3d.js +45 -0
  121. package/ui/forge_ui/static/modelstats.js +249 -0
  122. package/ui/forge_ui/static/notify.js +179 -0
  123. package/ui/forge_ui/static/panel.js +526 -0
  124. package/ui/forge_ui/static/runmodel.js +651 -0
  125. package/ui/forge_ui/static/scene3d.js +996 -0
  126. package/ui/forge_ui/static/search.js +119 -0
  127. package/ui/forge_ui/static/style.css +618 -0
@@ -0,0 +1,261 @@
1
+ """Read-only managed artifacts and pinned SQLite ledger projections."""
2
+ from __future__ import annotations
3
+
4
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5
+ import contextlib
6
+ import json
7
+ from pathlib import Path
8
+ import secrets
9
+ import sqlite3
10
+ import time
11
+ from urllib.parse import parse_qs, urlparse
12
+ import webbrowser
13
+
14
+ from . import SCRIPTS, identifier, managed_run, read_json, safe_file, state_root
15
+
16
+
17
+ def runs(repository: str | None = None) -> dict:
18
+ result, diagnostics = [], []
19
+ root = state_root() / 'runs'
20
+ if root.is_dir():
21
+ for path in sorted(root.iterdir())[:1000]:
22
+ try:
23
+ run = managed_run(path.name)
24
+ config = read_json(run / 'run.json')
25
+ if repository and config['repository'] != str(Path(repository).resolve()):
26
+ continue
27
+ result.append(config)
28
+ except (ValueError, OSError, KeyError) as error:
29
+ diagnostics.append(dict(run=path.name, error=str(error)))
30
+ return dict(runs=result[:200], diagnostics=diagnostics)
31
+
32
+
33
+ def read_text(root: Path, name: str, maximum: int | None = 262144) -> dict:
34
+ try:
35
+ path = safe_file(root, name)
36
+ if maximum is None:
37
+ content = path.read_bytes()
38
+ else:
39
+ with path.open('rb') as stream:
40
+ content = stream.read(maximum)
41
+ return dict(text=content.decode(errors='replace'), truncated=maximum is not None and path.stat().st_size > len(content))
42
+ except (ValueError, OSError) as error:
43
+ return dict(missing=str(error))
44
+
45
+
46
+ def ledger(task: Path, table: str, offset: int, limit: int) -> dict:
47
+ if table not in ('nodes', 'runs', 'iters', 'steps', 'events', 'messages'):
48
+ raise ValueError('Unsupported ledger projection')
49
+ try:
50
+ initialization = read_json(safe_file(task, 'initialization.json'))
51
+ relative = str(Path(initialization['ledger']).relative_to(task))
52
+ path = safe_file(task, relative)
53
+ # No Node construction: several upstream inspection verbs reconcile state.
54
+ with contextlib.closing(sqlite3.connect(path.as_uri() + '?mode=ro', uri=True, timeout=.2)) as connection:
55
+ connection.execute('PRAGMA query_only=ON')
56
+ connection.row_factory = sqlite3.Row
57
+ rows = connection.execute(f'SELECT * FROM {table} LIMIT ? OFFSET ?', (limit, offset)).fetchall()
58
+ return dict(rows=[dict(row) for row in rows], offset=offset, limit=limit)
59
+ except (OSError, ValueError, KeyError, sqlite3.Error) as error:
60
+ return dict(missing=str(error))
61
+
62
+
63
+ def _step_logs(task: Path, directory: Path, maximum: int | None) -> dict:
64
+ result = {}
65
+ for step in sorted(directory.glob('steps/*')):
66
+ request = step / 'request.json'
67
+ role = read_json(safe_file(task, str(request.relative_to(task)))).get('role', 'dwarf') if request.exists() else 'dwarf'
68
+ if role not in ('dwarf', 'planner'):
69
+ raise ValueError('Invalid step role')
70
+ for suffix in (role + '.log', role + '.resolved', role + '.last', 'dispatch.out', 'events.jsonl'):
71
+ result[str((step / suffix).relative_to(directory))] = read_text(task, str((step / suffix).relative_to(task)), maximum)
72
+ return result
73
+
74
+
75
+ def capture(run: Path, *, task_id: str | None = None, node_id: str | None = None,
76
+ logs: bool = False, offset: int = 0, limit: int = 100, portable: bool = False) -> dict:
77
+ data = dict(config=read_json(run / 'run.json'), captured_at=time.time(), tasks=[], missing=[])
78
+ task_root = run / 'tasks'
79
+ for task in sorted(task_root.iterdir()) if task_root.exists() else []:
80
+ if task_id and task.name != task_id:
81
+ continue
82
+ safe_file(run, str(task.relative_to(run)))
83
+ state = read_json(safe_file(task, 'state.json'))
84
+ request = read_json(safe_file(task, 'request.json'))
85
+ item = dict(id=task.name, state=state, request=request, nodes=[], acceptance='pending', verification='unverified')
86
+ # Only explicitly captured Forge outcome files are read from runner directories.
87
+ outcome = task / 'outcome.json'
88
+ if outcome.exists():
89
+ item.update(read_json(safe_file(task, 'outcome.json')))
90
+ if logs:
91
+ item['qa'] = {name: read_text(task, name, None if portable else 262144) for name in ('qa.last', 'qa.log', 'changes.diff')}
92
+ for path in sorted((task / 'nodes').glob('*/node.json')):
93
+ if node_id and path.parent.name != node_id:
94
+ continue
95
+ node = read_json(safe_file(task, str(path.relative_to(task))))
96
+ if logs:
97
+ node['logs'] = _step_logs(task, path.parent, None if portable else 262144)
98
+ node['candidate_changes'] = read_text(task, str((path.parent / 'candidate.diff').relative_to(task)), None if portable else 262144)
99
+ item['nodes'].append(node)
100
+ item['activity'] = ledger(task, 'events', offset, limit)
101
+ item['coordinator_events'] = read_text(task, 'events.jsonl', None if portable else 262144)
102
+ item['steps'] = ledger(task, 'steps', offset, limit)
103
+ item['messages'] = ledger(task, 'messages', offset, limit)
104
+ if portable:
105
+ for field, table in (('activity', 'events'), ('steps', 'steps'), ('messages', 'messages')):
106
+ records = []
107
+ page_offset = 0
108
+ while True:
109
+ page = ledger(task, table, page_offset, 1000)
110
+ if 'missing' in page:
111
+ item[field] = page
112
+ break
113
+ records.extend(page['rows'])
114
+ if len(page['rows']) < 1000:
115
+ item[field] = dict(rows=records, complete=True)
116
+ break
117
+ page_offset += 1000
118
+ if logs:
119
+ item['coordinator_log'] = read_text(task, 'coordinator.log', None if portable else 262144)
120
+ data['tasks'].append(item)
121
+ if task_id and not data['tasks']:
122
+ raise ValueError('Task selector did not match a managed task')
123
+ if node_id and not any(task['nodes'] for task in data['tasks']):
124
+ raise ValueError('Node selector did not match a managed node')
125
+ return data
126
+
127
+
128
+ def progress(run: Path) -> dict:
129
+ """Small, read-only projection for polling; no ledger or text artifacts."""
130
+ data = dict(config=read_json(safe_file(run, 'run.json')), captured_at=time.time(), tasks=[])
131
+ task_root = run / 'tasks'
132
+ for task in sorted(task_root.iterdir()) if task_root.exists() else []:
133
+ safe_file(run, str(task.relative_to(run)))
134
+ request = read_json(safe_file(task, 'request.json'))
135
+ item = dict(id=task.name, goal=str(request.get('prompt', ''))[:240],
136
+ model=request.get('model'), state=read_json(safe_file(task, 'state.json')),
137
+ nodes=[], acceptance='pending', verification='unverified')
138
+ if (task / 'outcome.json').exists():
139
+ item.update(read_json(safe_file(task, 'outcome.json')))
140
+ for path in sorted((task / 'nodes').glob('*/node.json')):
141
+ node = read_json(safe_file(task, str(path.relative_to(task))))
142
+ item['nodes'].append({key: node.get(key) for key in (
143
+ 'id', 'parent', 'status', 'model', 'iteration', 'cost', 'unknown_cost_steps',
144
+ 'difficulty', 'paths', 'deps', 'phase')}
145
+ | dict(goal=str(node.get('goal', ''))[:240], error=str(node.get('error', ''))[:240]))
146
+ data['tasks'].append(item)
147
+ return data
148
+
149
+
150
+ def scoped(run: Path, task_id: str, *, node_id: str | None = None,
151
+ artifact: str = 'task', table: str = 'events', offset: int = 0) -> dict:
152
+ """Read only the selected task or node's requested heavy material."""
153
+ task = safe_file(run, 'tasks/' + identifier(task_id))
154
+ if not task.is_dir():
155
+ raise ValueError('Task selector did not match a managed task')
156
+ if artifact == 'task':
157
+ return dict(request=read_json(safe_file(task, 'request.json')),
158
+ outcome=read_json(safe_file(task, 'outcome.json')) if (task / 'outcome.json').exists() else {})
159
+ if artifact == 'history':
160
+ if table not in ('events', 'steps', 'messages'):
161
+ raise ValueError('Unsupported history table')
162
+ return ledger(task, table, offset, 100)
163
+ if artifact == 'qa':
164
+ return {name: read_text(task, name) for name in ('qa.last', 'qa.log', 'changes.diff', 'coordinator.log')}
165
+ if artifact == 'node' and node_id:
166
+ node = safe_file(task, 'nodes/' + identifier(node_id))
167
+ if not (node / 'node.json').is_file():
168
+ raise ValueError('Node selector did not match a managed node')
169
+ return read_json(safe_file(task, 'nodes/' + node_id + '/node.json'))
170
+ if artifact not in ('logs', 'changes') or not node_id:
171
+ raise ValueError('Unsupported scoped artifact')
172
+ node = safe_file(task, 'nodes/' + identifier(node_id))
173
+ if not (node / 'node.json').is_file():
174
+ raise ValueError('Node selector did not match a managed node')
175
+ if artifact == 'logs':
176
+ return _step_logs(task, node, 262144)
177
+ return read_text(task, str((node / 'candidate.diff').relative_to(task)))
178
+
179
+
180
+ def html(data: dict, live: bool = False) -> str:
181
+ template = (SCRIPTS / 'forge_fractal/dashboard.html').read_text()
182
+ css = (SCRIPTS / 'forge_fractal/dashboard.css').read_text()
183
+ script = (SCRIPTS / 'forge_fractal/dashboard.js').read_text()
184
+ embedded = json.dumps(data).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
185
+ return (template.replace('/*__STYLE__*/', css).replace('/*__SCRIPT__*/', script)
186
+ .replace('__LIVE__', 'true' if live else 'false').replace('__CAPTURE__', embedded))
187
+
188
+
189
+ def _http_data(selected: str | None, query: dict) -> dict:
190
+ if query.get('scope') == ['runs'] or not selected:
191
+ return runs()
192
+ run = managed_run(selected)
193
+ if query.get('scope') == ['progress']:
194
+ return progress(run)
195
+ if query.get('scope') == ['artifact']:
196
+ offset = int(query.get('offset', ['0'])[0])
197
+ if offset < 0:
198
+ raise ValueError('History offset must be nonnegative')
199
+ return scoped(run, query.get('task', [''])[0], node_id=query.get('node', [None])[0],
200
+ artifact=query.get('artifact', ['task'])[0],
201
+ table=query.get('table', ['events'])[0], offset=offset)
202
+ return capture(run, logs=query.get('logs') == ['1'],
203
+ offset=max(0, int(query.get('offset', ['0'])[0])), limit=100)
204
+
205
+
206
+ class DashboardHandler(BaseHTTPRequestHandler):
207
+ def log_message(self, *args):
208
+ pass # never log the access token
209
+
210
+ def do_GET(self):
211
+ target = urlparse(self.path)
212
+ prefix = self.server.dashboard_prefix
213
+ if self.headers.get('Host') != f'127.0.0.1:{self.server.server_port}' or not target.path.startswith(prefix):
214
+ self.send_error(403)
215
+ return
216
+ route = target.path[len(prefix):]
217
+ if route not in ('', 'data'):
218
+ self.send_error(404)
219
+ return
220
+ query = parse_qs(target.query, keep_blank_values=True)
221
+ selected = query.get('run', [self.server.dashboard_run_id])[0]
222
+ try:
223
+ if route == 'data':
224
+ body = json.dumps(_http_data(selected, query)).encode()
225
+ content_type = 'application/json'
226
+ else:
227
+ data = progress(managed_run(selected)) if selected else runs()
228
+ body = html(data, live=True).encode()
229
+ content_type = 'text/html; charset=utf-8'
230
+ except (OSError, ValueError, KeyError) as error:
231
+ body = json.dumps(dict(error=str(error))).encode()
232
+ content_type = 'application/json'
233
+ self.send_response(400)
234
+ else:
235
+ self.send_response(200)
236
+ self.send_header('Content-Type', content_type)
237
+ self.send_header('Cache-Control', 'no-store')
238
+ self.send_header('Referrer-Policy', 'no-referrer')
239
+ self.send_header('X-Content-Type-Options', 'nosniff')
240
+ self.send_header('Content-Length', str(len(body)))
241
+ self.end_headers()
242
+ self.wfile.write(body)
243
+
244
+
245
+ def make_server(run_id: str | None, port: int = 0) -> tuple[ThreadingHTTPServer, str]:
246
+ prefix = '/' + secrets.token_urlsafe(32) + '/'
247
+ server = ThreadingHTTPServer(('127.0.0.1', port), DashboardHandler)
248
+ server.dashboard_prefix = prefix
249
+ server.dashboard_run_id = run_id
250
+ return server, f'http://127.0.0.1:{server.server_port}{prefix}'
251
+
252
+
253
+ def serve(run_id: str | None, port: int = 0) -> None:
254
+ server, url = make_server(run_id, port)
255
+ print('Read-only dashboard: ' + url, flush=True)
256
+ print('Ctrl-C closes the dashboard; execution continues.', flush=True)
257
+ webbrowser.open(url)
258
+ try:
259
+ server.serve_forever()
260
+ finally:
261
+ server.server_close()
@@ -0,0 +1,158 @@
1
+ """Versioned, isolated installation; no shell profile or activation preference."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import platform
9
+ import shutil
10
+ import sys
11
+ import tarfile
12
+ import time
13
+ import urllib.request
14
+
15
+ from . import ARCHIVE_SHA256, REVISION, VERSION, SCRIPTS, command, lock, read_json, runtime_python, state_root, write_json
16
+
17
+ UV_VERSION = '0.8.22'
18
+ UV_HASHES = {
19
+ 'aarch64-apple-darwin': '3f61099e261e449527141dbf125629fab33ad696468c8c90cebbac40185a306c',
20
+ 'x86_64-apple-darwin': '76638fdcfa91357858771551a1c88de1f7c3b270b33ab1866f8a0618d9e442d8',
21
+ 'aarch64-unknown-linux-gnu': '726b72a137fda33565143325f7d31c42cd30ff9ccdf067e00d124d37b4081cb2',
22
+ 'x86_64-unknown-linux-gnu': '741ff1f5742c5a4a25d2f829e8395355e43f7a5ae2ebc6368e9ae2df0efb69cf',
23
+ }
24
+
25
+
26
+ def compatible_python() -> str | None:
27
+ for name in (sys.executable, 'python3.14', 'python3.13', 'python3.12'):
28
+ binary = shutil.which(name)
29
+ if binary:
30
+ try:
31
+ version = json.loads(command([binary, '-c', 'import sys,json; print(json.dumps(list(sys.version_info[:2])))']))
32
+ if (3, 12) <= tuple(version) < (3, 15):
33
+ return binary
34
+ except (RuntimeError, ValueError):
35
+ pass
36
+ return None
37
+
38
+
39
+ def tmux_install() -> list[str] | None:
40
+ if platform.system() == 'Darwin' and shutil.which('brew'):
41
+ return [shutil.which('brew'), 'install', 'tmux']
42
+ release = Path('/etc/os-release')
43
+ if release.exists() and any(v in release.read_text().lower() for v in ('debian', 'ubuntu')) and shutil.which('apt-get'):
44
+ return ([] if os.geteuid() == 0 else ['sudo', '-n']) + ['apt-get', 'install', '-y', 'tmux']
45
+ return None
46
+
47
+
48
+ def doctor() -> dict:
49
+ python = runtime_python()
50
+ data = dict(version=VERSION, revision=REVISION, python=str(python), tmux=shutil.which('tmux'), ready=False)
51
+ provenance = state_root() / 'provenance.json'
52
+ try:
53
+ recorded = read_json(provenance)
54
+ if recorded['revision'] != REVISION or recorded['archive_sha256'] != ARCHIVE_SHA256:
55
+ raise ValueError('Managed provenance does not match the pinned revision')
56
+ code = (f'import sys; sys.path.insert(0, {str(SCRIPTS)!r}); '
57
+ 'from forge_fractal.bridge import ForgeAgent; assert not ForgeAgent.enforces_budget; '
58
+ 'import importlib.metadata as m; from fractal.core.agent import Agent, Invocation, StreamParser, resolve; '
59
+ 'from fractal.core.node import Node; '
60
+ 'assert m.version("plasma-fractal")=="1.2.0"; print(m.version("plasma-wiki"))')
61
+ data['wiki_version'] = command([python, '-c', code], timeout=20).decode().strip()
62
+ if not (python.parent / 'wiki').is_file():
63
+ raise ValueError('Managed wiki executable is missing')
64
+ data['ready'] = bool(data['tmux'])
65
+ if not data['ready']:
66
+ data['error'] = 'tmux is missing'
67
+ except (OSError, RuntimeError, ValueError, KeyError) as error:
68
+ data['error'] = str(error)
69
+ data['remedy'] = 'forge fractal install --yes --with-prerequisites'
70
+ return data
71
+
72
+
73
+ def download(url: str, path: Path, digest: str) -> None:
74
+ with urllib.request.urlopen(url, timeout=45) as response, path.open('wb') as output:
75
+ shutil.copyfileobj(response, output)
76
+ if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
77
+ raise RuntimeError('Checksum mismatch: ' + url + '; retained ' + str(path))
78
+
79
+
80
+ def install(args) -> int:
81
+ root = state_root()
82
+ uv = shutil.which('uv') or (str(root / 'bin/uv') if (root / 'bin/uv').is_file() else None)
83
+ python = compatible_python()
84
+ tmux = shutil.which('tmux')
85
+ prereq = tmux_install()
86
+ target = ('aarch64' if platform.machine() in ('arm64', 'aarch64') else platform.machine()) + (
87
+ '-apple-darwin' if platform.system() == 'Darwin' else '-unknown-linux-gnu')
88
+ preview = dict(destination=str(root / 'runtime'), version=VERSION, revision=REVISION,
89
+ downloads=[f'https://codeload.github.com/plasma-ai/fractal/tar.gz/{REVISION}',
90
+ 'Fractal dependencies from PyPI, including plasma-wiki'],
91
+ uv=uv or f'Official uv {UV_VERSION} checksum-verified release for {target}',
92
+ python=python or 'uv python install 3.13',
93
+ tmux=tmux or prereq or 'Install tmux with your system package manager; on macOS install Homebrew first',
94
+ activation='off; each new run makes a separate choice')
95
+ print(json.dumps(preview, indent=2))
96
+ if args.dry_run:
97
+ return 0
98
+ if doctor()['ready']:
99
+ print('Pinned managed runtime already installed; no changes.')
100
+ return 0
101
+ missing = not uv or not python or not tmux
102
+ if not args.yes:
103
+ if not sys.stdin.isatty():
104
+ raise ValueError('Unattended installation requires --yes; prerequisites also require --with-prerequisites')
105
+ if input('Install the managed Fractal runtime? [y/N] ').lower() != 'y':
106
+ return 0
107
+ if missing and not args.with_prerequisites:
108
+ raise ValueError('Missing prerequisites shown above. Install them, or explicitly use --yes --with-prerequisites')
109
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
110
+ with lock(root / 'install.lock'):
111
+ attempt = root / 'installs' / str(time.time_ns())
112
+ attempt.mkdir(parents=True)
113
+ write_json(attempt / 'preview.json', preview)
114
+ try:
115
+ if not uv:
116
+ if target not in UV_HASHES:
117
+ raise ValueError('Unsupported uv bootstrap platform; install uv >=0.8 manually')
118
+ archive = attempt / 'uv.tar.gz'
119
+ download(f'https://github.com/astral-sh/uv/releases/download/{UV_VERSION}/uv-{target}.tar.gz', archive, UV_HASHES[target])
120
+ with tarfile.open(archive) as tar:
121
+ member = next(m for m in tar.getmembers() if m.name.endswith('/uv') and m.isfile())
122
+ (root / 'bin').mkdir(exist_ok=True)
123
+ destination = root / 'bin/uv'
124
+ with tar.extractfile(member) as src, destination.open('wb') as dst:
125
+ shutil.copyfileobj(src, dst)
126
+ destination.chmod(0o755)
127
+ uv = str(destination)
128
+ if not python:
129
+ command([uv, 'python', 'install', '3.13'], timeout=600)
130
+ python = command([uv, 'python', 'find', '3.13']).decode().strip()
131
+ if not tmux:
132
+ if prereq is None:
133
+ raise ValueError(str(preview['tmux']))
134
+ try:
135
+ command(prereq, timeout=600)
136
+ except RuntimeError as error:
137
+ raise RuntimeError(f'tmux installation failed. Run {prereq!r} in a terminal with privileges, then retry. {error}') from error
138
+ archive = attempt / 'fractal.tar.gz'
139
+ download(preview['downloads'][0], archive, ARCHIVE_SHA256)
140
+ # A failed runtime is retained and never overlays unrelated environments.
141
+ destination = root / 'runtime'
142
+ if destination.exists():
143
+ destination.rename(attempt / 'previous-runtime')
144
+ command([uv, 'venv', destination, '--python', python], timeout=120)
145
+ output = command([uv, 'pip', 'install', '--python', destination / 'bin/python', archive], timeout=600)
146
+ (attempt / 'install.log').write_bytes(output)
147
+ versions = command([uv, 'pip', 'freeze', '--python', destination / 'bin/python']).decode()
148
+ write_json(root / 'provenance.json', dict(version=VERSION, revision=REVISION,
149
+ archive_sha256=ARCHIVE_SHA256, source=preview['downloads'][0], versions=versions,
150
+ uv=command([uv, '--version']).decode().strip(), installed_at=time.time()))
151
+ result = doctor()
152
+ if not result['ready']:
153
+ raise RuntimeError(str(result))
154
+ print('Installed. Fractal remains opt-in for each run.')
155
+ except Exception as error:
156
+ (attempt / 'error.txt').write_text(str(error))
157
+ raise
158
+ return 0
@@ -0,0 +1,94 @@
1
+ """Provider invocation lifecycle shared by Fractal planning and implementation."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import threading
6
+ import time
7
+ import uuid
8
+
9
+ from . import read_json, write_json
10
+
11
+
12
+ def _dataset_step(task, node: dict, directory: Path, **values) -> None:
13
+ """Record the step in the run dataset (scripts/forge_dataset). Never raises."""
14
+ try:
15
+ from forge_dataset.capture import fractal_step
16
+ fractal_step(task, node, directory, **values)
17
+ except Exception:
18
+ pass
19
+
20
+
21
+ def run_step(tree, node: dict, workspace: Path, prompt: str, *, phase: str = 'implementation') -> tuple[int, str]:
22
+ from .execution import Halt, event, process_identity, slot, terminate
23
+ from fractal.core.node import Node
24
+ from fractal.core.agent import resolve
25
+ directory = tree.task / 'nodes' / node['id'] / 'steps' / uuid.uuid4().hex
26
+ directory.mkdir(parents=True)
27
+ planning = phase == 'planning'
28
+ role = 'planner' if planning else 'dwarf'
29
+ spec = tree.request['planner'] if planning else node['model']
30
+ resolutions = tree.config['decomposition']['resolved'] if planning else tree.config['resolved']
31
+ resolved = next(item['canonical'] for item in resolutions if item['spec'] == spec)
32
+ iteration = node['decomposition']['attempts'] if planning else node['iteration']
33
+ with slot(tree.run, lambda: tree.check(node)):
34
+ tree.pause_gate(node)
35
+ fractal = Node(node['control'])
36
+ agent = resolve('forge-bridge', root=Node(tree.task / 'control').db.path.parent)(fractal)
37
+ agent.forge_step, agent.forge_workspace = directory, workspace
38
+ remaining = max(1, tree.limits['deadline'] - tree.elapsed)
39
+ if tree.request.get('step_timeout', 0) > 0:
40
+ remaining = min(remaining, tree.request['step_timeout'])
41
+ write_json(directory / 'request.json', dict(workspace=str(workspace),
42
+ yolo=False if planning else tree.config['yolo_dwarf'], role=role, remaining=remaining))
43
+ tree.update(node, **{('resolved_planner' if planning else 'resolved_model'): resolved})
44
+ invocation = agent.invocation(prompt, model=resolved)
45
+ record = fractal.record
46
+ run_id = record.run_start()
47
+ iter_id = record.iter_start(run_id=run_id, iter=iteration)
48
+ step_id = record.step_start(run_id=run_id, iter_id=iter_id, step=1,
49
+ step_name='FORGE_PLAN' if planning else 'FORGE_WORK')
50
+ tree.update(node, status='active', phase=phase, started_at=time.time(),
51
+ **{('planning_step' if planning else 'step'): str(directory.relative_to(tree.task))})
52
+ output = directory / 'events.jsonl'
53
+ rc = 1
54
+ cost = None
55
+ try:
56
+ with output.open('w') as stream:
57
+ process = agent.spawn(invocation, start_new_session=True)
58
+ write_json(directory / 'process.json', dict(pid=process.pid, identity=process_identity(process.pid)))
59
+ def drain():
60
+ for line in process.stdout:
61
+ stream.write(line)
62
+ stream.flush()
63
+ reader = threading.Thread(target=drain, daemon=True)
64
+ reader.start()
65
+ try:
66
+ while process.poll() is None:
67
+ tree.check(node)
68
+ if tree.control(node) == 'pause':
69
+ raise Halt('paused')
70
+ time.sleep(.1)
71
+ rc = process.returncode
72
+ finally:
73
+ terminate(process)
74
+ reader.join(timeout=3)
75
+ if rc == 0:
76
+ result = agent.stream(output.read_text(errors='replace').splitlines(), step_id=step_id, model=resolved, detached=True)
77
+ cost = result.cost
78
+ finally:
79
+ status = 'completed' if rc == 0 else 'exited'
80
+ record.step_end(step_id=step_id, status=status, exit_code=rc)
81
+ record.iter_end(iter_id=iter_id, status=status, exit_code=rc)
82
+ record.run_end(run_id=run_id, status=status, exit_code=rc)
83
+ last = directory / (role + '.last')
84
+ text = last.read_text(errors='replace') if last.exists() else ''
85
+ attempts = sorted(directory.glob('attempts/' + role + '-*/metrics.json'))
86
+ if attempts:
87
+ metrics = read_json(attempts[-1])
88
+ tree.update(node, **{('planning_tokens' if planning else 'tokens'):
89
+ {key: metrics.get(key) for key in ('input_tokens', 'cached_input_tokens', 'output_tokens')}})
90
+ tree.update(node, cost=(node.get('cost') or 0) + cost if cost is not None else node.get('cost'),
91
+ unknown_cost_steps=node.get('unknown_cost_steps', 0) + int(cost is None))
92
+ event(tree.task, 'step_finished', node=node['id'], phase=phase, iteration=iteration, exit_code=rc, cost=cost)
93
+ _dataset_step(tree.task, node, directory, phase=phase, iteration=iteration, rc=rc, cost=cost, resolved=resolved)
94
+ return rc, text
@@ -0,0 +1,124 @@
1
+ """Durable Forge pipeline receipts and explicit retry preparation."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import shutil
6
+ import shlex
7
+ import subprocess
8
+ import time
9
+
10
+ from . import artifact, lock, read_json, write_json
11
+
12
+
13
+ def remember(run: Path, argv: list[str]) -> None:
14
+ with lock(run / 'pipeline-record.lock'):
15
+ config = read_json(run / 'run.json')
16
+ if 'pipeline_argv' not in config:
17
+ config.update(pipeline_argv=['/bin/bash', *argv], pipeline_cwd=str(Path.cwd()))
18
+ write_json(run / 'run.json', config)
19
+
20
+
21
+ def resume_pipeline(run: Path) -> bool:
22
+ config = read_json(run / 'run.json')
23
+ if 'pipeline_argv' not in config:
24
+ return False
25
+ runner = Path(config['runner'])
26
+ path = runner / ('pipeline.lock' if config['mode'] == 'solo' else 'schedule.lock')
27
+ try:
28
+ with lock(path, blocking=False):
29
+ pass
30
+ except BlockingIOError:
31
+ return False # Original runner is still waiting and will continue itself.
32
+ if config['mode'] == 'parallel':
33
+ for task in (run / 'tasks').iterdir():
34
+ request = read_json(task / 'request.json')
35
+ status = Path(request['output']) / 'status'
36
+ if status.exists() and status.read_text().strip() in ('RUNNING', 'INTERRUPTED', 'ERROR', 'TIMEOUT', 'INFRA'):
37
+ status.write_text('PENDING\n')
38
+ script = run / 'resume-pipeline.sh'
39
+ script.write_text('#!/bin/sh\ncd ' + shlex.quote(config['pipeline_cwd']) + '\nexec ' +
40
+ shlex.join(config['pipeline_argv']) + ' >>' + shlex.quote(str(run / 'pipeline.log')) + ' 2>&1\n')
41
+ name = 'forge-pipeline-' + config['id']
42
+ result = subprocess.run(['tmux', '-L', name, '-f', '/dev/null', 'new-session', '-d', '-s', name,
43
+ '/bin/sh ' + shlex.quote(str(script))], capture_output=True)
44
+ if result.returncode and b'duplicate session' not in result.stderr:
45
+ raise RuntimeError(result.stderr.decode())
46
+ return True
47
+
48
+
49
+ def outcome(run: Path, runner: Path, rc: int) -> None:
50
+ config = read_json(run / 'run.json')
51
+ for task in (run / 'tasks').iterdir() if (run / 'tasks').exists() else []:
52
+ request = read_json(task / 'request.json')
53
+ output = Path(request['output'])
54
+ if output != runner and str(runner) != config['runner']:
55
+ continue
56
+ data = dict(acceptance='pending', verification='unverified', pipeline_exit_code=rc, captured_at=time.time())
57
+ for name, field in (('verdict', 'acceptance'), ('status', 'task_status'), ('verification.status', 'verification')):
58
+ path = output / name
59
+ if path.is_file():
60
+ data[field] = path.read_text().strip()
61
+ if config['mode'] == 'parallel':
62
+ data['acceptance'] = data.get('task_status', 'pending')
63
+ verification = Path(config['runner']) / 'verification.status'
64
+ if verification.exists():
65
+ data['verification'] = verification.read_text().strip()
66
+ for name in ('qa.last', 'qa.log', 'changes.diff'):
67
+ path = output / name
68
+ if path.is_file():
69
+ shutil.copyfile(path, task / name)
70
+ if Path(request['repo']).exists():
71
+ data['fingerprint'] = artifact('forge_fingerprint', request['repo'])
72
+ write_json(task / 'outcome.json', data)
73
+ try:
74
+ from forge_dataset.capture import fractal_outcome
75
+ fractal_outcome(task)
76
+ except Exception:
77
+ pass
78
+
79
+
80
+ def checkpoint(run: Path, source: Path) -> bool:
81
+ config = read_json(run / 'run.json')
82
+ if config['mode'] != 'solo':
83
+ return False
84
+ for task in (run / 'tasks').iterdir() if (run / 'tasks').exists() else []:
85
+ path = task / 'outcome.json'
86
+ if path.exists():
87
+ data = read_json(path)
88
+ if data['acceptance'] == 'PASS' and data['pipeline_exit_code'] == 0:
89
+ if artifact('forge_fingerprint', source) != data['fingerprint']:
90
+ raise ValueError('Source changed after accepted QA; cannot reuse this checkpoint')
91
+ print('Forge QA checkpoint retained; no model invocation repeated.')
92
+ return True
93
+ return False
94
+
95
+
96
+ def retry(run: Path, task: Path, prompt: str, model: str) -> None:
97
+ from .decomposition import retry_decision
98
+ config = read_json(run / 'run.json')
99
+ if model not in config['eligible']:
100
+ raise ValueError('Retry model is outside the frozen routing pool; create a new run to change the pool')
101
+ with lock(task / 'owner.lock', blocking=False):
102
+ previous = task / 'attempts' / str(time.time_ns())
103
+ previous.mkdir(parents=True)
104
+ for name in ('state.json', 'request.json', 'outcome.json'):
105
+ if (task / name).exists():
106
+ shutil.copyfile(task / name, previous / name)
107
+ request = read_json(task / 'request.json')
108
+ request.update(prompt=prompt, model=model)
109
+ write_json(task / 'request.json', request)
110
+ for path in (task / 'nodes').glob('*/node.json'):
111
+ node = read_json(path)
112
+ shutil.copyfile(path, previous / (node['id'] + '.json'))
113
+ retry_decision(node, prompt)
114
+ if node['id'] == 'implementation':
115
+ node.update(goal=prompt, model=model, status='pending', iteration=0,
116
+ fingerprint=artifact('forge_fingerprint', request['repo']),
117
+ base=artifact('forge_tree', request['repo']))
118
+ intent = path.parent / 'candidate.import.json'
119
+ if intent.exists():
120
+ intent.rename(previous / 'implementation-import.json')
121
+ elif node['status'] != 'completed':
122
+ node.update(status='pending', iteration=0)
123
+ write_json(path, node)
124
+ write_json(task / 'state.json', dict(status='pending', acceptance='pending', elapsed=0))