@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.
- package/CHANGELOG.md +181 -0
- package/LICENSE +21 -0
- package/README.md +1854 -2
- package/SKILL.md +122 -0
- package/VERSION +1 -0
- package/forge +74 -0
- package/package.json +26 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +353 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +665 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runtime.py +533 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +198 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +83 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +287 -0
- package/ui/THIRD_PARTY_NOTICES.md +69 -0
- package/ui/docs/api.md +888 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +204 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +583 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1336 -0
- package/ui/forge_ui/server.py +303 -0
- package/ui/forge_ui/static/app.js +924 -0
- package/ui/forge_ui/static/cloud3d.js +646 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +394 -0
- package/ui/forge_ui/static/diffview.js +216 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/index.html +55 -0
- package/ui/forge_ui/static/iso.js +765 -0
- package/ui/forge_ui/static/leaderboard.js +315 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +526 -0
- package/ui/forge_ui/static/runmodel.js +651 -0
- package/ui/forge_ui/static/scene3d.js +996 -0
- package/ui/forge_ui/static/search.js +119 -0
- 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))
|