@giamme/forge 0.0.0-stage → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Fractal child tasks of a forge task: the managed run scripts/forge_fractal keeps under
|
|
2
|
+
$XDG_STATE_HOME/forge/fractal, read without importing that package and without writing.
|
|
3
|
+
|
|
4
|
+
Every open walks directory fds one component at a time with O_NOFOLLOW, so a symlink anywhere
|
|
5
|
+
under the state root is refused. Only run.json, a task's request/state/outcome/events files,
|
|
6
|
+
nodes/, nodes/<n>/node.json and nodes/<n>/candidate.diff are ever opened: never control/,
|
|
7
|
+
product/, planning/, steps/ or the ledger.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import errno
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from forge_ui import diffs, executions
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SELECTION = 'fractal-selection.json'
|
|
24
|
+
_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]{0,100}')
|
|
25
|
+
_RUN = re.compile(r'run-[0-9a-f]{20}')
|
|
26
|
+
_DIR = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
27
|
+
_FILE = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0) | getattr(os, 'O_CLOEXEC', 0)
|
|
28
|
+
_KIB = 1024
|
|
29
|
+
_LIMITS = {SELECTION: 64 * _KIB, 'run.json': 1024 * _KIB, 'request.json': 2048 * _KIB, 'state.json': 256 * _KIB,
|
|
30
|
+
'outcome.json': 256 * _KIB, 'node.json': 4096 * _KIB, 'events.jsonl': 64 * _KIB}
|
|
31
|
+
_MAX_NODES = 500
|
|
32
|
+
_EVENTS = 50
|
|
33
|
+
_HISTORY = 20
|
|
34
|
+
_UI_STATUS = dict(pending='PENDING', active='RUNNING', waiting='RUNNING', paused='BLOCKED', completed='MERGED',
|
|
35
|
+
timeout='TIMEOUT', interrupted='INTERRUPTED', stopped='INTERRUPTED', reserved='PENDING')
|
|
36
|
+
_EVENT_FIELDS = ('phase', 'decision', 'status', 'acceptance', 'exit_code', 'iteration', 'cost', 'difficulty', 'model')
|
|
37
|
+
_CACHE = executions._SigCache(4096)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Skip(Exception):
|
|
41
|
+
"""A file or dir that is refused or unreadable; its message is the reason."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def supported():
|
|
45
|
+
return (os.open in os.supports_dir_fd and os.stat in os.supports_dir_fd and os.scandir in os.supports_fd
|
|
46
|
+
and bool(getattr(os, 'O_NOFOLLOW', 0)) and bool(getattr(os, 'O_DIRECTORY', 0)))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def state_root():
|
|
50
|
+
"""forge_fractal.state_root(): an empty XDG_STATE_HOME counts as unset."""
|
|
51
|
+
base = os.environ.get('XDG_STATE_HOME') or os.path.join(os.path.expanduser('~'), '.local', 'state')
|
|
52
|
+
return os.path.join(os.path.realpath(os.path.expanduser(base)), 'forge', 'fractal')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_id(path):
|
|
56
|
+
return 'run-' + hashlib.sha256(str(os.path.realpath(path)).encode()).hexdigest()[:20]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def task_id(target):
|
|
60
|
+
return 'task-' + hashlib.sha256(str(target).encode()).hexdigest()[:16]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _why(exc):
|
|
64
|
+
if isinstance(exc, _Skip):
|
|
65
|
+
return str(exc)
|
|
66
|
+
if isinstance(exc, FileNotFoundError):
|
|
67
|
+
return 'missing'
|
|
68
|
+
if isinstance(exc, OSError) and exc.errno in (errno.ELOOP, errno.EMLINK): # O_NOFOLLOW met a symlink
|
|
69
|
+
return 'a symlink'
|
|
70
|
+
if isinstance(exc, OSError) and exc.errno == errno.ENOTDIR: # Linux says so for O_DIRECTORY on a symlink
|
|
71
|
+
return 'a symlink or not a directory'
|
|
72
|
+
return f'cannot read ({type(exc).__name__})'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _dirs(names):
|
|
76
|
+
"""fd of state_root()/names..., each component opened without following a symlink."""
|
|
77
|
+
fd = os.open(state_root(), _DIR)
|
|
78
|
+
try:
|
|
79
|
+
for name in names:
|
|
80
|
+
child = os.open(name, _DIR, dir_fd=fd)
|
|
81
|
+
os.close(fd)
|
|
82
|
+
fd = child
|
|
83
|
+
except BaseException:
|
|
84
|
+
os.close(fd)
|
|
85
|
+
raise
|
|
86
|
+
return fd
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _where(run, *parts):
|
|
90
|
+
"""The path a managed file has, for cache keys and nothing else: reads go through dir fds."""
|
|
91
|
+
if parts and parts[0].startswith('task-'):
|
|
92
|
+
parts = ('tasks',) + parts
|
|
93
|
+
return os.path.join(state_root(), 'runs', run, *parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _subdir(parent, name):
|
|
97
|
+
return os.open(name, _DIR, dir_fd=parent)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read(parent, name, limit, key=None, parse=None, over=None):
|
|
101
|
+
"""parse(bytes, cut, size) of a regular file in a dir fd, kept by its signature under `key` (a parse
|
|
102
|
+
kind and the file's path: an inode is reused, a path seldom is). A file over `limit` bytes is
|
|
103
|
+
refused, unless `over` says which `limit` bytes to read: 'head' or 'tail'."""
|
|
104
|
+
fd = os.open(name, _FILE, dir_fd=parent)
|
|
105
|
+
try:
|
|
106
|
+
info = os.fstat(fd)
|
|
107
|
+
if not stat.S_ISREG(info.st_mode):
|
|
108
|
+
raise _Skip('not a regular file')
|
|
109
|
+
if info.st_size > limit and over is None:
|
|
110
|
+
raise _Skip(f'larger than {limit // _KIB} KiB')
|
|
111
|
+
sig = executions._signature(info)
|
|
112
|
+
if key is not None:
|
|
113
|
+
hit = _CACHE.get(key, sig)
|
|
114
|
+
if hit is not None:
|
|
115
|
+
return hit[0]
|
|
116
|
+
cut = info.st_size > limit
|
|
117
|
+
if cut and over == 'tail':
|
|
118
|
+
os.lseek(fd, info.st_size - limit, os.SEEK_SET)
|
|
119
|
+
chunks, left = [], limit
|
|
120
|
+
while left > 0:
|
|
121
|
+
chunk = os.read(fd, min(left, 1 << 20))
|
|
122
|
+
if not chunk:
|
|
123
|
+
break
|
|
124
|
+
chunks.append(chunk)
|
|
125
|
+
left -= len(chunk)
|
|
126
|
+
value = parse(b''.join(chunks), cut, info.st_size)
|
|
127
|
+
if key is not None and abs(time.time() - info.st_mtime) > executions._SETTLE_S:
|
|
128
|
+
_CACHE.put(key, sig, (value,))
|
|
129
|
+
return value
|
|
130
|
+
finally:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _object(raw, cut=False):
|
|
135
|
+
def invalid(value):
|
|
136
|
+
raise ValueError('non-finite JSON constant')
|
|
137
|
+
try:
|
|
138
|
+
obj = json.loads(raw.decode('utf-8', 'replace'), parse_constant=invalid)
|
|
139
|
+
if not isinstance(obj, dict):
|
|
140
|
+
raise ValueError()
|
|
141
|
+
json.dumps(obj, allow_nan=False)
|
|
142
|
+
return obj
|
|
143
|
+
except (ValueError, RecursionError, OverflowError):
|
|
144
|
+
raise _Skip('not a JSON object')
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- small projections ------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _word(value, size=240):
|
|
150
|
+
if not isinstance(value, str):
|
|
151
|
+
return None
|
|
152
|
+
return value if len(value) <= size else value[:size] + '…'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tail(value, size):
|
|
156
|
+
if not isinstance(value, str):
|
|
157
|
+
return None
|
|
158
|
+
return value if len(value) <= size else '…' + value[-size:]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _number(value):
|
|
162
|
+
return float(value) if type(value) in (int, float) and math.isfinite(value) else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _int(value):
|
|
166
|
+
return value if type(value) is int else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _strings(value):
|
|
170
|
+
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _tokens(value):
|
|
174
|
+
if not isinstance(value, dict):
|
|
175
|
+
return None
|
|
176
|
+
return {key: _int(value.get(key)) for key in ('input_tokens', 'cached_input_tokens', 'output_tokens')}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _admitted(decision):
|
|
180
|
+
"""The valid child names one decomposition decision admitted, with their rows."""
|
|
181
|
+
rows = decision.get('admission') if isinstance(decision, dict) else None
|
|
182
|
+
return [row for row in rows if isinstance(row, dict) and isinstance(row.get('name'), str)
|
|
183
|
+
and _NAME.fullmatch(row['name'])] if isinstance(rows, list) else []
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decision(value, size, full=False):
|
|
187
|
+
if not isinstance(value, dict):
|
|
188
|
+
return None
|
|
189
|
+
result = dict(phase=_word(value.get('phase'), 64), decision=_word(value.get('decision'), 64),
|
|
190
|
+
reason=_word(value.get('reason'), size))
|
|
191
|
+
if full:
|
|
192
|
+
result.update(attempts=_int(value.get('attempts')), error=_word(value.get('error'), size),
|
|
193
|
+
admission=[dict(name=row['name'], goal=_word(row.get('goal'), size),
|
|
194
|
+
difficulty=_word(row.get('difficulty'), 64), paths=_strings(row.get('paths')),
|
|
195
|
+
deps=_strings(row.get('deps')), model=_word(row.get('model')))
|
|
196
|
+
for row in _admitted(value)])
|
|
197
|
+
else:
|
|
198
|
+
result['children'] = [row['name'] for row in _admitted(value)]
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _status(value):
|
|
203
|
+
status = value if isinstance(value, str) and value else None
|
|
204
|
+
return status, 'UNKNOWN' if status is None else _UI_STATUS.get(status, 'FAIL')
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _project(name, node):
|
|
208
|
+
"""A node.json as the list shows it. Absolute paths (control, workspace, source, candidate,
|
|
209
|
+
...) and the large texts are left out."""
|
|
210
|
+
status, ui_status = _status(node.get('status'))
|
|
211
|
+
parent = node.get('parent')
|
|
212
|
+
history = node.get('decomposition_history')
|
|
213
|
+
# Admitted children not yet created are listed as reserved (forge_fractal.decomposition.reserved_nodes).
|
|
214
|
+
admitted = [row for item in (history if isinstance(history, list) else []) + [node.get('decomposition')]
|
|
215
|
+
for row in _admitted(item)]
|
|
216
|
+
parent = parent if isinstance(parent, str) and _NAME.fullmatch(parent) else None
|
|
217
|
+
return dict(_admitted=admitted, id=name, parent=parent, depth=0,
|
|
218
|
+
goal=_word(node.get('goal')), difficulty=_word(node.get('difficulty'), 64),
|
|
219
|
+
paths=_strings(node.get('paths')), deps=_strings(node.get('deps')), model=_word(node.get('model')),
|
|
220
|
+
resolved_model=_word(node.get('resolved_model')), status=status, ui_status=ui_status,
|
|
221
|
+
phase=_word(node.get('phase'), 64), iteration=_int(node.get('iteration')),
|
|
222
|
+
started_at=_number(node.get('started_at')), ended_at=_number(node.get('ended_at')),
|
|
223
|
+
cost=_number(node.get('cost')), unknown_cost_steps=_int(node.get('unknown_cost_steps')),
|
|
224
|
+
tokens=_tokens(node.get('tokens')), decomposition=_decision(node.get('decomposition'), 2000),
|
|
225
|
+
error=_word(node.get('error')), has_diff=False, reserved=False)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _reserved(name, parent, row):
|
|
229
|
+
return dict(id=name, parent=parent, depth=0, goal=_word(row.get('goal')), difficulty=_word(row.get('difficulty'), 64),
|
|
230
|
+
paths=_strings(row.get('paths')), deps=_strings(row.get('deps')), model=_word(row.get('model')),
|
|
231
|
+
resolved_model=None, status='reserved', ui_status='PENDING', phase=None, iteration=None,
|
|
232
|
+
started_at=None, ended_at=None, cost=None, unknown_cost_steps=None, tokens=None, decomposition=None,
|
|
233
|
+
error=None, has_diff=False, reserved=True)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _detail(node):
|
|
237
|
+
"""What the node endpoint adds to the projection, and the names of the texts it cut."""
|
|
238
|
+
history = node.get('decomposition_history')
|
|
239
|
+
history = [_decision(item, 2000, True) for item in (history if isinstance(history, list) else [])
|
|
240
|
+
if isinstance(item, dict)]
|
|
241
|
+
texts = ('goal', 'last', 'planning_last', 'error')
|
|
242
|
+
cut = [name for name in texts if isinstance(node.get(name), str) and len(node[name]) > 20000]
|
|
243
|
+
if len(history) > _HISTORY:
|
|
244
|
+
cut.append('decomposition_history')
|
|
245
|
+
return dict(goal=_word(node.get('goal'), 20000), last=_tail(node.get('last'), 20000),
|
|
246
|
+
planning_last=_tail(node.get('planning_last'), 20000), error=_word(node.get('error'), 20000),
|
|
247
|
+
decomposition=_decision(node.get('decomposition'), 20000, True),
|
|
248
|
+
planning_tokens=_tokens(node.get('planning_tokens')), decomposition_history=history[-_HISTORY:],
|
|
249
|
+
truncated=cut)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _state(obj):
|
|
253
|
+
return dict(status=_word(obj.get('status'), 64), acceptance=_word(obj.get('acceptance'), 64),
|
|
254
|
+
elapsed=_number(obj.get('elapsed')), started_at=_number(obj.get('started_at')),
|
|
255
|
+
ended_at=_number(obj.get('ended_at')), exit_code=_int(obj.get('exit_code')),
|
|
256
|
+
error=_word(obj.get('error'), 2000), qa_status=_word(obj.get('qa_status'), 64))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _event(obj):
|
|
260
|
+
event = dict(at=_number(obj.get('at')), kind=_word(obj.get('kind'), 64), node=_word(obj.get('node'), 101))
|
|
261
|
+
for key in _EVENT_FIELDS:
|
|
262
|
+
value = obj.get(key)
|
|
263
|
+
if isinstance(value, str):
|
|
264
|
+
event[key] = _word(value)
|
|
265
|
+
elif type(value) in (int, float) and math.isfinite(value):
|
|
266
|
+
event[key] = value
|
|
267
|
+
if isinstance(obj.get('reason'), str):
|
|
268
|
+
event['reason'] = _word(obj['reason'])
|
|
269
|
+
return event
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _events(raw, cut, size):
|
|
273
|
+
lines = raw.decode('utf-8', 'replace').split('\n')
|
|
274
|
+
if cut:
|
|
275
|
+
lines = lines[1:] # a tail read starts inside a line
|
|
276
|
+
events, bad = [], 0
|
|
277
|
+
for line in lines:
|
|
278
|
+
if not line.strip():
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
obj = _object(line.encode('utf-8'))
|
|
282
|
+
except _Skip:
|
|
283
|
+
bad += 1
|
|
284
|
+
continue
|
|
285
|
+
if isinstance(obj.get('kind'), str):
|
|
286
|
+
events.append(_event(obj))
|
|
287
|
+
return events[-_EVENTS:], bad
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# --- the run, its selection and its tasks -----------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _selection(reader):
|
|
293
|
+
"""(enabled, run id or None, reason or None) of fractal-selection.json, or None without one."""
|
|
294
|
+
if not os.path.lexists(reader.path / SELECTION):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
def parse(text, cut, size):
|
|
298
|
+
if size > _LIMITS[SELECTION]:
|
|
299
|
+
reader.warn(SELECTION, 'larger than 64 KiB')
|
|
300
|
+
return False, None, 'invalid ' + SELECTION
|
|
301
|
+
obj = reader.object(SELECTION, text)
|
|
302
|
+
if obj is None:
|
|
303
|
+
return False, None, 'invalid ' + SELECTION
|
|
304
|
+
if obj.get('enabled') is not True:
|
|
305
|
+
return False, None, 'Fractal is off for this run'
|
|
306
|
+
run = obj.get('run')
|
|
307
|
+
if not isinstance(run, str) or not _RUN.fullmatch(run):
|
|
308
|
+
reader.warn(SELECTION, 'invalid run id')
|
|
309
|
+
return True, None, 'invalid run id'
|
|
310
|
+
return True, run, None
|
|
311
|
+
return diffs.cached(reader, SELECTION, 'fractal-selection', parse, limit=_LIMITS[SELECTION] + 1) or (
|
|
312
|
+
False, None, 'invalid ' + SELECTION)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _run_config(run, warn):
|
|
316
|
+
"""run.json of a managed run, or a reason it cannot be used."""
|
|
317
|
+
if not supported():
|
|
318
|
+
return None, 'not supported on this platform'
|
|
319
|
+
try:
|
|
320
|
+
fd = _dirs(['runs', run])
|
|
321
|
+
except OSError as exc:
|
|
322
|
+
if not isinstance(exc, FileNotFoundError):
|
|
323
|
+
warn(f'fractal {run}', _why(exc))
|
|
324
|
+
return None, 'managed run not found'
|
|
325
|
+
try:
|
|
326
|
+
return _read(fd, 'run.json', _LIMITS['run.json'], 'object:' + _where(run, 'run.json'),
|
|
327
|
+
lambda raw, *_: _object(raw)), None
|
|
328
|
+
except (OSError, _Skip) as exc:
|
|
329
|
+
warn(f'fractal {run}/run.json', _why(exc))
|
|
330
|
+
return None, 'invalid run.json'
|
|
331
|
+
finally:
|
|
332
|
+
os.close(fd)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def summary(reader):
|
|
336
|
+
"""RunDetail `fractal`: {enabled, run, available, reason}, or None without fractal-selection.json."""
|
|
337
|
+
selected = _selection(reader)
|
|
338
|
+
if selected is None:
|
|
339
|
+
return None
|
|
340
|
+
enabled, run, reason = selected
|
|
341
|
+
if reason is None:
|
|
342
|
+
config, reason = _run_config(run, reader.warn)
|
|
343
|
+
if config is not None and config.get('runner') != str(reader.path):
|
|
344
|
+
reason = 'runner mismatch'
|
|
345
|
+
return dict(enabled=enabled, run=run, available=reason is None, reason=reason)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _open(reader, key, ident, target):
|
|
349
|
+
"""(TaskFractal skeleton, fd of the managed task dir or None). The caller closes the fd."""
|
|
350
|
+
result = dict(run=key, task=ident, fractal_run=None, fractal_task=task_id(target), available=False, reason=None,
|
|
351
|
+
state=None, outcome=None, nodes=[], events=[], warnings=reader.warnings, truncated=[])
|
|
352
|
+
head = summary(reader)
|
|
353
|
+
if head is None:
|
|
354
|
+
result['reason'] = 'Fractal is not selected for this run'
|
|
355
|
+
return result, None
|
|
356
|
+
result['fractal_run'] = head['run']
|
|
357
|
+
if not head['available']:
|
|
358
|
+
result['reason'] = head['reason']
|
|
359
|
+
return result, None
|
|
360
|
+
label = result['fractal_task']
|
|
361
|
+
try:
|
|
362
|
+
fd = _dirs(['runs', head['run'], 'tasks', label])
|
|
363
|
+
except OSError as exc:
|
|
364
|
+
if not isinstance(exc, FileNotFoundError):
|
|
365
|
+
reader.warn(f'fractal {label}', _why(exc))
|
|
366
|
+
result['reason'] = 'no Fractal task for this task yet'
|
|
367
|
+
return result, None
|
|
368
|
+
try:
|
|
369
|
+
request = _read(fd, 'request.json', _LIMITS['request.json'],
|
|
370
|
+
'object:' + _where(head['run'], label, 'request.json'), lambda raw, *_: _object(raw))
|
|
371
|
+
if request.get('output') != str(target):
|
|
372
|
+
result['reason'] = 'request does not match this task'
|
|
373
|
+
except FileNotFoundError:
|
|
374
|
+
result['reason'] = 'no Fractal task for this task yet'
|
|
375
|
+
except (OSError, _Skip) as exc:
|
|
376
|
+
reader.warn(f'fractal {label}/request.json', _why(exc))
|
|
377
|
+
result['reason'] = 'invalid request.json'
|
|
378
|
+
if result['reason'] is not None:
|
|
379
|
+
os.close(fd)
|
|
380
|
+
return result, None
|
|
381
|
+
result['available'] = True
|
|
382
|
+
return result, fd
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _object_file(reader, fd, label, where, name):
|
|
386
|
+
try:
|
|
387
|
+
return _read(fd, name, _LIMITS[name], 'object:' + os.path.join(where, name), lambda raw, *_: _object(raw))
|
|
388
|
+
except FileNotFoundError:
|
|
389
|
+
return None
|
|
390
|
+
except (OSError, _Skip) as exc:
|
|
391
|
+
reader.warn(f'{label}/{name}', _why(exc))
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _nodes(reader, fd, label, where, truncated):
|
|
396
|
+
"""The projection of every node dir; its private `_admitted` rows name the reserved children."""
|
|
397
|
+
try:
|
|
398
|
+
nodes_fd = _subdir(fd, 'nodes')
|
|
399
|
+
except FileNotFoundError:
|
|
400
|
+
return []
|
|
401
|
+
except OSError as exc:
|
|
402
|
+
reader.warn(f'{label}/nodes', _why(exc))
|
|
403
|
+
return []
|
|
404
|
+
found = []
|
|
405
|
+
try:
|
|
406
|
+
with os.scandir(nodes_fd) as stream:
|
|
407
|
+
names = sorted(entry.name for entry in stream
|
|
408
|
+
if _NAME.fullmatch(entry.name) and entry.is_dir(follow_symlinks=False))
|
|
409
|
+
if len(names) > _MAX_NODES:
|
|
410
|
+
names = names[:_MAX_NODES]
|
|
411
|
+
truncated.append('nodes')
|
|
412
|
+
for name in names:
|
|
413
|
+
try:
|
|
414
|
+
node_fd = _subdir(nodes_fd, name)
|
|
415
|
+
except OSError as exc:
|
|
416
|
+
reader.warn(f'{label}/nodes/{name}', _why(exc))
|
|
417
|
+
continue
|
|
418
|
+
try:
|
|
419
|
+
projection = dict(_read(node_fd, 'node.json', _LIMITS['node.json'],
|
|
420
|
+
'node:' + os.path.join(where, 'nodes', name, 'node.json'),
|
|
421
|
+
lambda raw, *_: _project(name, _object(raw))))
|
|
422
|
+
try:
|
|
423
|
+
projection['has_diff'] = stat.S_ISREG(os.stat('candidate.diff', dir_fd=node_fd,
|
|
424
|
+
follow_symlinks=False).st_mode)
|
|
425
|
+
except (OSError, NotImplementedError):
|
|
426
|
+
pass
|
|
427
|
+
found.append(projection)
|
|
428
|
+
except FileNotFoundError:
|
|
429
|
+
continue
|
|
430
|
+
except (OSError, _Skip) as exc:
|
|
431
|
+
reader.warn(f'{label}/nodes/{name}/node.json', _why(exc))
|
|
432
|
+
finally:
|
|
433
|
+
os.close(node_fd)
|
|
434
|
+
except OSError as exc:
|
|
435
|
+
reader.warn(f'{label}/nodes', _why(exc))
|
|
436
|
+
finally:
|
|
437
|
+
os.close(nodes_fd)
|
|
438
|
+
return found
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _tree(nodes, reserved):
|
|
442
|
+
"""Nodes in tree order (each parent before its children), reserved children included, with depths."""
|
|
443
|
+
by_id = {node['id']: node for node in nodes}
|
|
444
|
+
for parent, rows in reserved:
|
|
445
|
+
for row in rows:
|
|
446
|
+
if row['name'] not in by_id:
|
|
447
|
+
by_id[row['name']] = _reserved(row['name'], parent, row)
|
|
448
|
+
children = {}
|
|
449
|
+
for node in by_id.values():
|
|
450
|
+
children.setdefault(node['parent'] if node['parent'] in by_id else None, []).append(node)
|
|
451
|
+
ordered, seen = [], set()
|
|
452
|
+
|
|
453
|
+
def visit(node, depth):
|
|
454
|
+
if node['id'] in seen:
|
|
455
|
+
return
|
|
456
|
+
seen.add(node['id'])
|
|
457
|
+
node['depth'] = depth
|
|
458
|
+
ordered.append(node)
|
|
459
|
+
for child in sorted(children.get(node['id'], []), key=lambda item: item['id']):
|
|
460
|
+
visit(child, depth + 1)
|
|
461
|
+
for root in sorted(children.get(None, []), key=lambda item: item['id']):
|
|
462
|
+
visit(root, 0)
|
|
463
|
+
for node in sorted(by_id.values(), key=lambda item: item['id']): # a parent cycle has no root
|
|
464
|
+
visit(node, 0)
|
|
465
|
+
return ordered
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _all_nodes(reader, fd, label, where, truncated):
|
|
469
|
+
nodes = _nodes(reader, fd, label, where, truncated)
|
|
470
|
+
reserved = []
|
|
471
|
+
for node in nodes:
|
|
472
|
+
reserved.append((node['id'], node.pop('_admitted', [])))
|
|
473
|
+
return _tree(nodes, reserved)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def task(reader, key, ident, target):
|
|
477
|
+
"""TaskFractal of one forge task; `target` is the realpath forge handed Fractal as --run-dir."""
|
|
478
|
+
result, fd = _open(reader, key, ident, target)
|
|
479
|
+
if fd is None:
|
|
480
|
+
return result
|
|
481
|
+
label, where = 'fractal ' + result['fractal_task'], _where(result['fractal_run'], result['fractal_task'])
|
|
482
|
+
try:
|
|
483
|
+
state = _object_file(reader, fd, label, where, 'state.json')
|
|
484
|
+
outcome = _object_file(reader, fd, label, where, 'outcome.json')
|
|
485
|
+
result['state'] = _state(state) if state is not None else None
|
|
486
|
+
result['outcome'] = None if outcome is None else dict(
|
|
487
|
+
acceptance=_word(outcome.get('acceptance'), 64), verification=_word(outcome.get('verification'), 64),
|
|
488
|
+
task_status=_word(outcome.get('task_status'), 64))
|
|
489
|
+
result['nodes'] = _all_nodes(reader, fd, label, where, result['truncated'])
|
|
490
|
+
try:
|
|
491
|
+
events, bad = _read(fd, 'events.jsonl', _LIMITS['events.jsonl'], None, _events, over='tail')
|
|
492
|
+
result['events'] = events
|
|
493
|
+
if bad:
|
|
494
|
+
reader.warn(f'{label}/events.jsonl', f'{bad} lines are not JSON objects')
|
|
495
|
+
except FileNotFoundError:
|
|
496
|
+
pass
|
|
497
|
+
except (OSError, _Skip) as exc:
|
|
498
|
+
reader.warn(f'{label}/events.jsonl', _why(exc))
|
|
499
|
+
finally:
|
|
500
|
+
os.close(fd)
|
|
501
|
+
return result
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _node_fd(fd, name):
|
|
505
|
+
nodes_fd = _subdir(fd, 'nodes')
|
|
506
|
+
try:
|
|
507
|
+
return _subdir(nodes_fd, name)
|
|
508
|
+
finally:
|
|
509
|
+
os.close(nodes_fd)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def node(reader, key, ident, target, name):
|
|
513
|
+
"""NodeDetail of one node (reserved ones too), or None when there is no such node."""
|
|
514
|
+
if not _NAME.fullmatch(name or ''):
|
|
515
|
+
return None
|
|
516
|
+
result, fd = _open(reader, key, ident, target)
|
|
517
|
+
if fd is None:
|
|
518
|
+
return None
|
|
519
|
+
label, where = 'fractal ' + result['fractal_task'], _where(result['fractal_run'], result['fractal_task'])
|
|
520
|
+
try:
|
|
521
|
+
listed = next((item for item in _all_nodes(reader, fd, label, where, []) if item['id'] == name), None)
|
|
522
|
+
if listed is None:
|
|
523
|
+
return None
|
|
524
|
+
extra = dict(goal=listed['goal'], last=None, planning_last=None, error=listed['error'], decomposition=None,
|
|
525
|
+
planning_tokens=None, decomposition_history=[], truncated=[])
|
|
526
|
+
if not listed['reserved']:
|
|
527
|
+
try:
|
|
528
|
+
node_fd = _node_fd(fd, name)
|
|
529
|
+
try:
|
|
530
|
+
extra = _read(node_fd, 'node.json', _LIMITS['node.json'],
|
|
531
|
+
'detail:' + os.path.join(where, 'nodes', name, 'node.json'),
|
|
532
|
+
lambda raw, *_: _detail(_object(raw)))
|
|
533
|
+
finally:
|
|
534
|
+
os.close(node_fd)
|
|
535
|
+
except (OSError, _Skip) as exc:
|
|
536
|
+
reader.warn(f'{label}/nodes/{name}/node.json', _why(exc))
|
|
537
|
+
return None
|
|
538
|
+
finally:
|
|
539
|
+
os.close(fd)
|
|
540
|
+
detail = dict(listed, **extra)
|
|
541
|
+
detail.update(run=key, task=ident, fractal_run=result['fractal_run'], fractal_task=result['fractal_task'],
|
|
542
|
+
warnings=reader.warnings, truncated=list(extra['truncated']))
|
|
543
|
+
return detail
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def node_diff(reader, key, ident, target, name):
|
|
547
|
+
"""DiffDetail of a node's candidate.diff, or None when there is no such node."""
|
|
548
|
+
if not _NAME.fullmatch(name or ''):
|
|
549
|
+
return None
|
|
550
|
+
result, fd = _open(reader, key, ident, target)
|
|
551
|
+
if fd is None:
|
|
552
|
+
return None
|
|
553
|
+
label, where = 'fractal ' + result['fractal_task'], _where(result['fractal_run'], result['fractal_task'])
|
|
554
|
+
loaded = reason = None
|
|
555
|
+
try:
|
|
556
|
+
if not any(item['id'] == name for item in _all_nodes(reader, fd, label, where, [])):
|
|
557
|
+
return None
|
|
558
|
+
try:
|
|
559
|
+
node_fd = _node_fd(fd, name)
|
|
560
|
+
try:
|
|
561
|
+
loaded = _read(node_fd, 'candidate.diff', diffs.MAX_BYTES, None, lambda *read: read, 'head')
|
|
562
|
+
finally:
|
|
563
|
+
os.close(node_fd)
|
|
564
|
+
except FileNotFoundError:
|
|
565
|
+
reason = 'no candidate diff yet'
|
|
566
|
+
except (OSError, _Skip) as exc:
|
|
567
|
+
reason = 'unreadable (see warnings)'
|
|
568
|
+
reader.warn(f'{label}/nodes/{name}/candidate.diff', _why(exc))
|
|
569
|
+
finally:
|
|
570
|
+
os.close(fd)
|
|
571
|
+
parsed = None
|
|
572
|
+
if loaded is not None:
|
|
573
|
+
raw, cut, size = loaded
|
|
574
|
+
if cut and b'\n' in raw:
|
|
575
|
+
raw = raw[:raw.rindex(b'\n') + 1]
|
|
576
|
+
parsed = diffs.parsed(raw.decode('utf-8', 'replace'), cut, size)
|
|
577
|
+
rel = f'{result["fractal_task"]}/nodes/{name}/candidate.diff'
|
|
578
|
+
return dict(run=key, source=dict(kind='fractal', id=name, which='candidate', execution=None, attempt=None, file=rel),
|
|
579
|
+
sources=[dict(id='candidate', label='candidate.diff', bytes=parsed['bytes'])] if parsed else [],
|
|
580
|
+
available=parsed is not None, reason=reason if parsed is None else None,
|
|
581
|
+
bytes=parsed['bytes'] if parsed else None, truncated=bool(parsed and parsed['truncated']),
|
|
582
|
+
files=parsed['files'] if parsed else [], findings=[], warnings=reader.warnings, truncated_fields=[])
|