@giamme/forge 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,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=[])