@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,436 @@
|
|
|
1
|
+
"""Executions: the attempts/<role>-<uuid>/ dirs forge-runtime.py writes, read without modifying them."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from forge_ui import findings
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NAME = re.compile(r'(task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
|
|
18
|
+
_KIND = re.compile(r'(final-review|integrate)-([0-9]+)-([0-9]+)')
|
|
19
|
+
_SHADOW = re.compile(r'[0-9]+-[0-9]+')
|
|
20
|
+
CONTAINERS = ('task', 'solo')
|
|
21
|
+
_MODEL_ROLES = ('dwarf', 'qa')
|
|
22
|
+
# attempts.tsv is appended right after the task execution's EXIT trap ran finish().
|
|
23
|
+
_MATCH_S = 120
|
|
24
|
+
# A rewrite within one mtime tick that keeps the size keeps the signature too, so a file
|
|
25
|
+
# this young is parsed again on every read instead of being cached.
|
|
26
|
+
_SETTLE_S = 3
|
|
27
|
+
_REQUIRED = ('started', 'wall_started', 'phase', 'phase_started', 'elapsed_s', 'exit_code')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _SigCache:
|
|
31
|
+
"""LRU of parsed values, valid while a file keeps its (dev, inode, size, mtime_ns)."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, size=8192):
|
|
34
|
+
self.size = size
|
|
35
|
+
self.lock = threading.Lock()
|
|
36
|
+
self.items = OrderedDict()
|
|
37
|
+
|
|
38
|
+
def get(self, key, sig):
|
|
39
|
+
with self.lock:
|
|
40
|
+
item = self.items.get(key)
|
|
41
|
+
if item is None or item[0] != sig:
|
|
42
|
+
return None
|
|
43
|
+
self.items.move_to_end(key)
|
|
44
|
+
return item[1]
|
|
45
|
+
|
|
46
|
+
def put(self, key, sig, value):
|
|
47
|
+
with self.lock:
|
|
48
|
+
self.items[key] = (sig, value)
|
|
49
|
+
self.items.move_to_end(key)
|
|
50
|
+
while len(self.items) > self.size:
|
|
51
|
+
self.items.popitem(last=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_CACHE = _SigCache()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Execution(dict):
|
|
58
|
+
"""An Execution as the API returns it. `dataset` and `rel` (its dir) stay out of the JSON."""
|
|
59
|
+
__slots__ = ('dataset', 'rel')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def inside(root, path) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
return os.path.commonpath([str(root), str(path)]) == str(root)
|
|
65
|
+
except ValueError:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _signature(info):
|
|
70
|
+
return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cached(reader, name, kind, parse):
|
|
74
|
+
"""parse(text) of one file through the cache; a hit replays the warnings the parse gave."""
|
|
75
|
+
path = reader.path / name
|
|
76
|
+
try:
|
|
77
|
+
info = path.stat()
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
if os.path.lexists(path):
|
|
80
|
+
reader.warn(name, 'dangling symlink')
|
|
81
|
+
return None
|
|
82
|
+
except (OSError, ValueError) as exc:
|
|
83
|
+
reader.warn(name, f'cannot stat ({type(exc).__name__})')
|
|
84
|
+
return None
|
|
85
|
+
key, sig = (kind, str(path)), _signature(info)
|
|
86
|
+
hit = _CACHE.get(key, sig)
|
|
87
|
+
if hit is not None:
|
|
88
|
+
reader.present_files.add(name)
|
|
89
|
+
reader.track(info)
|
|
90
|
+
for reason in hit[1]:
|
|
91
|
+
reader.warn(name, reason)
|
|
92
|
+
return hit[0]
|
|
93
|
+
mark = len(reader.warnings)
|
|
94
|
+
text = reader.text(name)
|
|
95
|
+
value = None if text is None else parse(text)
|
|
96
|
+
new = reader.warnings[mark:]
|
|
97
|
+
prefix = name + ': '
|
|
98
|
+
if text is not None and all(item.startswith(prefix) for item in new) and abs(time.time() - info.st_mtime) > _SETTLE_S:
|
|
99
|
+
try:
|
|
100
|
+
if _signature(path.stat()) == sig:
|
|
101
|
+
_CACHE.put(key, sig, (value, [item[len(prefix):] for item in new]))
|
|
102
|
+
except (OSError, ValueError):
|
|
103
|
+
pass
|
|
104
|
+
return value
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def summary_of(text):
|
|
108
|
+
"""FindingsSummary of a reply, memoised by content (live files are re-read, not re-parsed)."""
|
|
109
|
+
if not text or not findings.available():
|
|
110
|
+
return None
|
|
111
|
+
key = ('summary', hashlib.sha1(text.encode('utf-8', 'surrogatepass')).hexdigest())
|
|
112
|
+
hit = _CACHE.get(key, 0)
|
|
113
|
+
if hit is None:
|
|
114
|
+
hit = (findings.summarize(text),)
|
|
115
|
+
if hit[0] is not None:
|
|
116
|
+
_CACHE.put(key, 0, hit)
|
|
117
|
+
return dict(hit[0]) if hit[0] is not None else None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_resolved(text) -> dict:
|
|
121
|
+
result = {}
|
|
122
|
+
for line in (text or '').splitlines():
|
|
123
|
+
key, sep, value = line.partition('=')
|
|
124
|
+
if sep and key.strip():
|
|
125
|
+
result[key.strip()] = value.strip()
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def spec_of(resolved):
|
|
130
|
+
alias, effort = resolved.get('alias') or None, resolved.get('effort') or None
|
|
131
|
+
if not alias:
|
|
132
|
+
return None
|
|
133
|
+
# `<harness default>` is the dispatch placeholder, not an effort a caller can select.
|
|
134
|
+
if effort and effort != '<harness default>':
|
|
135
|
+
return alias + ':' + effort
|
|
136
|
+
return alias
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _model(resolved):
|
|
140
|
+
if not resolved or not resolved.get('alias'):
|
|
141
|
+
return None
|
|
142
|
+
effort = resolved.get('effort') or None
|
|
143
|
+
return dict(spec=spec_of(resolved), alias=resolved['alias'], harness=resolved.get('harness') or None,
|
|
144
|
+
model=resolved.get('model') or None,
|
|
145
|
+
effort=None if effort == '<harness default>' else effort,
|
|
146
|
+
clamped=resolved.get('clamped') or None)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _finite(value):
|
|
150
|
+
return value if value is not None and math.isfinite(value) else None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _metrics(reader, name, text):
|
|
154
|
+
obj = reader.object(name, text)
|
|
155
|
+
if obj is None:
|
|
156
|
+
return None
|
|
157
|
+
for key in _REQUIRED:
|
|
158
|
+
if key not in obj:
|
|
159
|
+
reader.warn(name, f'missing {key}')
|
|
160
|
+
|
|
161
|
+
def number(key, value, kinds=(int, float)):
|
|
162
|
+
if value is None:
|
|
163
|
+
return None
|
|
164
|
+
if type(value) in kinds: # bool is not a number here
|
|
165
|
+
if kinds == (int,):
|
|
166
|
+
return value
|
|
167
|
+
try:
|
|
168
|
+
return float(value)
|
|
169
|
+
except OverflowError:
|
|
170
|
+
pass
|
|
171
|
+
reader.warn(name, f'{key} is not ' + ('an integer' if kinds == (int,) else 'a number'))
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
wall = number('wall_started', obj.get('wall_started'))
|
|
175
|
+
mono = number('started', obj.get('started'))
|
|
176
|
+
mark = number('phase_started', obj.get('phase_started'))
|
|
177
|
+
exit_code = number('exit_code', obj.get('exit_code'), (int,))
|
|
178
|
+
phases, total = {}, None
|
|
179
|
+
elapsed = obj.get('elapsed_s')
|
|
180
|
+
if isinstance(elapsed, dict):
|
|
181
|
+
for key, value in elapsed.items():
|
|
182
|
+
value = number('elapsed_s.' + key, value)
|
|
183
|
+
if value is None:
|
|
184
|
+
continue
|
|
185
|
+
if key == 'total':
|
|
186
|
+
total = value
|
|
187
|
+
else:
|
|
188
|
+
phases[key] = value
|
|
189
|
+
elif elapsed is not None:
|
|
190
|
+
reader.warn(name, 'elapsed_s is not an object')
|
|
191
|
+
phase = obj.get('phase')
|
|
192
|
+
if phase is not None and not isinstance(phase, str):
|
|
193
|
+
reader.warn(name, 'phase is not a string')
|
|
194
|
+
phase = None
|
|
195
|
+
tokens = {short: number(key, obj.get(key), (int,)) for short, key in (
|
|
196
|
+
('input', 'input_tokens'), ('cached', 'cached_input_tokens'), ('output', 'output_tokens'))}
|
|
197
|
+
infra = obj.get('infra_class')
|
|
198
|
+
done = exit_code is not None
|
|
199
|
+
return dict(started=wall, ended=_finite(wall + total) if done and None not in (wall, total) else None,
|
|
200
|
+
duration_s=total if done else None, phase=phase,
|
|
201
|
+
phase_started=_finite(wall + (mark - mono)) if None not in (wall, mark, mono) else None,
|
|
202
|
+
phases=phases, exit_code=exit_code, infra_class=infra if isinstance(infra, str) and infra else None,
|
|
203
|
+
tokens=tokens if any(value is not None for value in tokens.values()) else None,
|
|
204
|
+
prompt_bytes=number('prompt_bytes', obj.get('prompt_bytes'), (int,)), shadow=obj.get('shadow') is True)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _dataset(reader, name, text):
|
|
208
|
+
obj = reader.object(name, text)
|
|
209
|
+
if obj is None:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def word(key):
|
|
213
|
+
value = obj.get(key)
|
|
214
|
+
return value if isinstance(value, str) and value else None
|
|
215
|
+
|
|
216
|
+
start = obj.get('attempt_start')
|
|
217
|
+
return dict(trigger=word('trigger'), parent=word('parent'),
|
|
218
|
+
attempt_start=start if type(start) is int else None, resume=word('resume'))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def qa_result(reader, name):
|
|
222
|
+
"""(verdict, FindingsSummary) of a reply that no longer changes once written (cached)."""
|
|
223
|
+
kind = 'qa' if findings.available() else 'qa-verdict'
|
|
224
|
+
verdict, summary = _cached(reader, name, kind,
|
|
225
|
+
lambda text: (findings.verdict(text), findings.summarize(text))) or (None, None)
|
|
226
|
+
return verdict, dict(summary) if summary is not None else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolved_of(reader, name):
|
|
230
|
+
return _cached(reader, name, 'resolved', parse_resolved)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def real_dir(reader, rel):
|
|
234
|
+
"""realpath of a dir under the run, or None when it resolves outside it."""
|
|
235
|
+
try:
|
|
236
|
+
real = os.path.realpath(reader.path / rel)
|
|
237
|
+
except (OSError, ValueError):
|
|
238
|
+
return None
|
|
239
|
+
if not inside(reader.path, real):
|
|
240
|
+
if os.path.isdir(real):
|
|
241
|
+
reader.warn(rel, 'outside the run dir')
|
|
242
|
+
return None
|
|
243
|
+
return real
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def scan(reader, rel_dir, owner_live, roles=None):
|
|
247
|
+
"""Every execution in <rel_dir>, ordered by start (unknown last), then id."""
|
|
248
|
+
base = real_dir(reader, rel_dir)
|
|
249
|
+
if base is None:
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
with os.scandir(base) as stream:
|
|
253
|
+
entries = sorted((entry for entry in stream if NAME.fullmatch(entry.name)), key=lambda entry: entry.name)
|
|
254
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
255
|
+
return []
|
|
256
|
+
except (OSError, ValueError) as exc:
|
|
257
|
+
reader.warn(rel_dir, f'cannot list ({type(exc).__name__})')
|
|
258
|
+
return []
|
|
259
|
+
result = []
|
|
260
|
+
for entry in entries:
|
|
261
|
+
role = entry.name.split('-', 1)[0]
|
|
262
|
+
rel = f'{rel_dir}/{entry.name}'
|
|
263
|
+
try:
|
|
264
|
+
if (roles and role not in roles) or not entry.is_dir():
|
|
265
|
+
continue
|
|
266
|
+
if entry.is_symlink() and not inside(reader.path, os.path.realpath(entry.path)):
|
|
267
|
+
reader.warn(rel, 'outside the run dir')
|
|
268
|
+
continue
|
|
269
|
+
except (OSError, ValueError):
|
|
270
|
+
continue
|
|
271
|
+
result.append(_build(reader, rel, entry.name, role))
|
|
272
|
+
result.sort(key=lambda item: (item['started'] is None, item['started'] or 0.0, item['id']))
|
|
273
|
+
newest = {item['role']: item['id'] for item in result}
|
|
274
|
+
for item in result:
|
|
275
|
+
if item['exit_code'] is not None:
|
|
276
|
+
item['state'] = 'done'
|
|
277
|
+
else:
|
|
278
|
+
live = owner_live(item['role']) if callable(owner_live) else owner_live
|
|
279
|
+
# Only the newest execution of a role can still be in flight: dispatches of one
|
|
280
|
+
# role into one dir run one after another.
|
|
281
|
+
item['state'] = 'running' if live and newest[item['role']] == item['id'] else 'orphaned'
|
|
282
|
+
owner = posixpath.dirname(rel_dir)
|
|
283
|
+
for item in result:
|
|
284
|
+
if item['role'] in _MODEL_ROLES and item['state'] == 'running' and item['model'] is None:
|
|
285
|
+
live = parse_resolved(reader.text(posixpath.join(owner, item['role'] + '.resolved')))
|
|
286
|
+
item['model'] = _model(live)
|
|
287
|
+
if item['role'] == 'qa' and item['state'] == 'done':
|
|
288
|
+
item['verdict'], item['findings'] = qa_result(reader, item.rel + '/qa.last')
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _build(reader, rel, name, role):
|
|
293
|
+
metrics_name, dataset_name = rel + '/metrics.json', rel + '/dataset.json'
|
|
294
|
+
metrics = _cached(reader, metrics_name, 'metrics', lambda text: _metrics(reader, metrics_name, text)) or {}
|
|
295
|
+
dataset = _cached(reader, dataset_name, 'dataset', lambda text: _dataset(reader, dataset_name, text))
|
|
296
|
+
model = timed_out = None
|
|
297
|
+
if role in _MODEL_ROLES:
|
|
298
|
+
model = _model(resolved_of(reader, f'{rel}/{role}.resolved'))
|
|
299
|
+
timed_out = reader.exists(f'{rel}/{role}.timedout')
|
|
300
|
+
item = Execution(id=name, role=role, parent=None, attempt=None, shadow=bool(metrics.get('shadow')),
|
|
301
|
+
state=None, started=metrics.get('started'), ended=metrics.get('ended'),
|
|
302
|
+
duration_s=metrics.get('duration_s'), phase=metrics.get('phase'),
|
|
303
|
+
phase_started=metrics.get('phase_started'), phases=dict(metrics.get('phases') or {}),
|
|
304
|
+
exit_code=metrics.get('exit_code'), infra_class=metrics.get('infra_class'),
|
|
305
|
+
timed_out=bool(timed_out), model=model,
|
|
306
|
+
tokens=dict(metrics['tokens']) if metrics.get('tokens') else None,
|
|
307
|
+
prompt_bytes=metrics.get('prompt_bytes'), verdict=None, findings=None)
|
|
308
|
+
item.dataset, item.rel = dataset, rel
|
|
309
|
+
return item
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def nest(execs, containers=None, adopt_state=True):
|
|
313
|
+
"""Give each dwarf/qa execution its parent task (or solo) execution.
|
|
314
|
+
|
|
315
|
+
The parent dataset.json names wins; otherwise the latest container whose run contains
|
|
316
|
+
this execution's start. A child cannot run on after its parent stopped.
|
|
317
|
+
"""
|
|
318
|
+
pool = [item for item in (execs if containers is None else containers) if item['role'] in CONTAINERS]
|
|
319
|
+
by_id = {item['id']: item for item in pool}
|
|
320
|
+
for item in execs:
|
|
321
|
+
if item['role'] in CONTAINERS:
|
|
322
|
+
continue
|
|
323
|
+
parent = by_id.get((item.dataset or {}).get('parent'))
|
|
324
|
+
if parent is None and item['started'] is not None:
|
|
325
|
+
for candidate in pool:
|
|
326
|
+
if (candidate['started'] is not None and candidate['started'] <= item['started']
|
|
327
|
+
and (candidate['ended'] is None or item['started'] <= candidate['ended'])):
|
|
328
|
+
parent = candidate
|
|
329
|
+
item['parent'] = parent['id'] if parent is not None else None
|
|
330
|
+
if adopt_state and parent is not None and item['state'] == 'running' and parent['state'] != 'running':
|
|
331
|
+
item['state'] = 'orphaned'
|
|
332
|
+
return execs
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def assign_attempts(execs, attempt_rows, current=None):
|
|
336
|
+
"""Attempt numbers: attempts.tsv rows by end time, then dataset.json, then `current` while running."""
|
|
337
|
+
pool = [item for item in execs if item['role'] in CONTAINERS]
|
|
338
|
+
pairs = sorted((abs(item['ended'] - row['at']), i, j)
|
|
339
|
+
for i, item in enumerate(pool) if item['ended'] is not None
|
|
340
|
+
for j, row in enumerate(attempt_rows) if abs(item['ended'] - row['at']) <= _MATCH_S)
|
|
341
|
+
taken, used = set(), set()
|
|
342
|
+
for _, i, j in pairs:
|
|
343
|
+
if i not in taken and j not in used:
|
|
344
|
+
taken.add(i)
|
|
345
|
+
used.add(j)
|
|
346
|
+
pool[i]['attempt'] = attempt_rows[j]['attempt']
|
|
347
|
+
for i, item in enumerate(pool):
|
|
348
|
+
if i in taken:
|
|
349
|
+
continue
|
|
350
|
+
start = (item.dataset or {}).get('attempt_start')
|
|
351
|
+
if start is not None:
|
|
352
|
+
# begin() runs before the counter moves; a QA-only resume does not move it.
|
|
353
|
+
item['attempt'] = start if (item.dataset or {}).get('resume') else start + 1
|
|
354
|
+
elif item['state'] == 'running':
|
|
355
|
+
item['attempt'] = current
|
|
356
|
+
by_id = {item['id']: item for item in pool}
|
|
357
|
+
for item in execs:
|
|
358
|
+
if item['role'] not in CONTAINERS:
|
|
359
|
+
parent = by_id.get(item['parent'])
|
|
360
|
+
item['attempt'] = parent['attempt'] if parent is not None else item['attempt']
|
|
361
|
+
return execs
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def review_dirs(reader):
|
|
365
|
+
"""(name, source, at, rel) of every final review, newest first: final-review-<e>-<p>/ and
|
|
366
|
+
integrate-<e>-<p>/final-review/. Also every integrate-<e>-<p>/, newest first, as
|
|
367
|
+
(name, at, its review's tuple or None)."""
|
|
368
|
+
try:
|
|
369
|
+
with os.scandir(reader.path) as stream:
|
|
370
|
+
names = sorted(entry.name for entry in stream if _KIND.fullmatch(entry.name))
|
|
371
|
+
except (OSError, ValueError) as exc:
|
|
372
|
+
reader.warn('.', f'cannot list ({type(exc).__name__})')
|
|
373
|
+
return [], []
|
|
374
|
+
reviews, integrations = [], []
|
|
375
|
+
for name in names:
|
|
376
|
+
kind, epoch, pid = _KIND.fullmatch(name).groups()
|
|
377
|
+
at = float(epoch)
|
|
378
|
+
if not math.isfinite(at) or len(epoch) > 15:
|
|
379
|
+
reader.warn(name, 'invalid epoch')
|
|
380
|
+
continue
|
|
381
|
+
real = real_dir(reader, name)
|
|
382
|
+
if real is None or not os.path.isdir(real):
|
|
383
|
+
continue
|
|
384
|
+
order = (at, int(pid), name)
|
|
385
|
+
if kind == 'final-review':
|
|
386
|
+
reviews.append((order, (name, 'review', at, name)))
|
|
387
|
+
continue
|
|
388
|
+
sub = real_dir(reader, name + '/final-review')
|
|
389
|
+
review = (name, 'integrate', at, name + '/final-review') if sub is not None and os.path.isdir(sub) else None
|
|
390
|
+
integrations.append((order, (name, at, review)))
|
|
391
|
+
if review is not None:
|
|
392
|
+
reviews.append((order, review))
|
|
393
|
+
reviews.sort(key=lambda item: item[0], reverse=True)
|
|
394
|
+
integrations.sort(key=lambda item: item[0], reverse=True)
|
|
395
|
+
return [item for _, item in reviews], [item for _, item in integrations]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def locate(run_path, name, task_ids):
|
|
399
|
+
"""Path (relative to the run dir) of execution `name`'s dir, or None.
|
|
400
|
+
|
|
401
|
+
Only the dirs executions are written to are looked at, never the whole tree, and a dir
|
|
402
|
+
that resolves outside the run (a symlinked attempts/, say) is never returned.
|
|
403
|
+
"""
|
|
404
|
+
if not NAME.fullmatch(name or ''):
|
|
405
|
+
return None
|
|
406
|
+
root = os.path.realpath(run_path)
|
|
407
|
+
|
|
408
|
+
def listed(rel, pattern):
|
|
409
|
+
try:
|
|
410
|
+
return sorted(child for child in os.listdir(os.path.join(root, rel)) if pattern.fullmatch(child))
|
|
411
|
+
except (OSError, ValueError):
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
def candidates():
|
|
415
|
+
yield 'attempts'
|
|
416
|
+
for ident in task_ids:
|
|
417
|
+
yield f'tasks/{ident}/attempts'
|
|
418
|
+
for ident in task_ids:
|
|
419
|
+
for child in listed(f'shadow/{ident}', _SHADOW):
|
|
420
|
+
yield f'shadow/{ident}/{child}/attempts'
|
|
421
|
+
kinds = listed('', _KIND)
|
|
422
|
+
for child in kinds:
|
|
423
|
+
if child.startswith('final-review-'):
|
|
424
|
+
yield child + '/attempts'
|
|
425
|
+
for child in kinds:
|
|
426
|
+
if child.startswith('integrate-'):
|
|
427
|
+
yield child + '/final-review/attempts'
|
|
428
|
+
|
|
429
|
+
for rel in candidates():
|
|
430
|
+
try:
|
|
431
|
+
real = os.path.realpath(os.path.join(root, rel, name))
|
|
432
|
+
if inside(root, real) and os.path.isdir(real):
|
|
433
|
+
return Path(rel) / name
|
|
434
|
+
except (OSError, ValueError):
|
|
435
|
+
continue
|
|
436
|
+
return None
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""One run as one HTML file that opens anywhere, offline included.
|
|
2
|
+
|
|
3
|
+
The page's own styles and scripts are inlined, and so is every API response the run view reads,
|
|
4
|
+
taken through the server's own routes (server.api_get), so the page finds them under the paths it
|
|
5
|
+
asks for. A Content-Security-Policy in the page lets it run only those inline scripts (by hash)
|
|
6
|
+
and load nothing but three.js from its pinned CDN path. The 3D stage's modules (iso.js, cloud3d.js,
|
|
7
|
+
scene3d.js) travel as one module text, imported through a blob: URL; without the CDN the page
|
|
8
|
+
shows the run in 2D.
|
|
9
|
+
|
|
10
|
+
By default the export leaves out what could be private: prompts, reports, logs, finding texts
|
|
11
|
+
(their headlines stay) and diffs (texts=False), and reduces local paths to their last component
|
|
12
|
+
(paths=False).
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
from forge_ui import __version__, server
|
|
26
|
+
|
|
27
|
+
STATIC = server.STATIC_DIR
|
|
28
|
+
# what the exported page runs, in index.html's order; the cloud, the leaderboard and notifications need a server
|
|
29
|
+
SCRIPTS = ('theme.js', 'icons.js', 'datatable.js', 'shell.js', 'runmodel.js', 'cloudmodel.js', 'graph.js', 'gantt.js', 'panel.js', 'diffview.js', 'convo.js', 'files.js', 'map3d.js',
|
|
30
|
+
'search.js', 'app.js')
|
|
31
|
+
MODULES = ('iso.js', 'cloud3d.js', 'findings3d.js', 'stagectl.js', 'scene3d.js') # bundled in dependency order
|
|
32
|
+
DIFF_CAP = 1 << 20 # one diff's JSON
|
|
33
|
+
TRANSCRIPT_CAP = 2 << 20 # one execution's transcript
|
|
34
|
+
TOTAL_CAP = 25 << 20 # every response together
|
|
35
|
+
CDN = 'https://cdn.jsdelivr.net/npm/three@0.160.0/'
|
|
36
|
+
_EXEC = re.compile(r'(?:task|dwarf|qa|solo|verification|parallel)-[0-9a-f]{32}')
|
|
37
|
+
# texts that can hold code, prompts or private notes: nulled unless texts=True
|
|
38
|
+
_TEXT_KEYS = frozenset(('prompt', 'last', 'request', 'log_tail', 'dwarf_tail', 'qa_tail', 'qa_last', 'qa_input',
|
|
39
|
+
'tasks_summary', 'known_issues', 'texts', 'text', 'planning_last', 'approach', 'retry_findings',
|
|
40
|
+
'dwarf_input', 'error'))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _enc(value):
|
|
44
|
+
"""encodeURIComponent, as the page builds its request paths."""
|
|
45
|
+
return urllib.parse.quote(str(value), safe="!~*'()")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sha(text):
|
|
49
|
+
return "'sha256-%s'" % base64.b64encode(hashlib.sha256(text.encode('utf-8')).digest()).decode('ascii')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _script_safe(text):
|
|
53
|
+
# inside a <script> element only "</script" (any case) and "<!--" can end or bend it
|
|
54
|
+
return re.sub(r'</(script)', r'<\\/\1', text, flags=re.IGNORECASE).replace('<!--', '<\\!--')
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def bundle():
|
|
58
|
+
"""The stage's ES modules as one module: third-party imports first, each module in its own scope,
|
|
59
|
+
relative imports bound to the earlier modules' exports, and createStage exported."""
|
|
60
|
+
imports, parts = [], []
|
|
61
|
+
for name in MODULES:
|
|
62
|
+
text = (STATIC / name).read_text(encoding='utf-8')
|
|
63
|
+
body, names, local = [], [], []
|
|
64
|
+
for line in text.split('\n'):
|
|
65
|
+
rel = re.match(r"import \{([^}]*)\} from '\./([A-Za-z0-9_]+)\.js';\s*$", line)
|
|
66
|
+
if rel:
|
|
67
|
+
local.append(f"const {{{rel.group(1)}}} = __{rel.group(2)};")
|
|
68
|
+
continue
|
|
69
|
+
if re.match(r"import .* from '[^.][^']*';\s*$", line):
|
|
70
|
+
if line not in imports:
|
|
71
|
+
imports.append(line)
|
|
72
|
+
continue
|
|
73
|
+
m = re.match(r'export (async function|function|const|let|class) ([A-Za-z_$][\w$]*)(.*)$', line)
|
|
74
|
+
if m:
|
|
75
|
+
names.append(m.group(2))
|
|
76
|
+
body.append(f'{m.group(1)} {m.group(2)}{m.group(3)}')
|
|
77
|
+
continue
|
|
78
|
+
m = re.match(r'export \{([^}]*)\};?\s*$', line)
|
|
79
|
+
if m:
|
|
80
|
+
names.extend(n.strip() for n in m.group(1).split(',') if n.strip())
|
|
81
|
+
continue
|
|
82
|
+
body.append(line)
|
|
83
|
+
stem = name[:-3]
|
|
84
|
+
parts.append(f"const __{stem} = (() => {{\n{chr(10).join(local)}\n{chr(10).join(body)}\nreturn {{ {', '.join(names)} }};\n}})();")
|
|
85
|
+
return '\n'.join(imports) + '\n' + '\n'.join(parts) + '\nconst { createStage } = __scene3d;\nexport { createStage };\n'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _Snapshot:
|
|
89
|
+
def __init__(self, catalog, key, texts, paths):
|
|
90
|
+
self.catalog, self.key, self.texts, self.paths = catalog, key, texts, paths
|
|
91
|
+
self.api, self.omitted, self.size = {}, [], 0
|
|
92
|
+
self.roots = []
|
|
93
|
+
|
|
94
|
+
def add(self, path, cap=None, required=False):
|
|
95
|
+
if path in self.api:
|
|
96
|
+
return self.api[path]
|
|
97
|
+
value = server.api_get(self.catalog, path)
|
|
98
|
+
if value is server.NO_ROUTE or value is None:
|
|
99
|
+
return None
|
|
100
|
+
value = self.clean(value)
|
|
101
|
+
size = len(json.dumps(value, ensure_ascii=False).encode('utf-8'))
|
|
102
|
+
if not required and ((cap and size > cap) or self.size + size > TOTAL_CAP):
|
|
103
|
+
self.omitted.append({'path': path, 'bytes': size, 'why': 'larger than 1 MB' if cap and size > cap else 'over the 25 MB total'})
|
|
104
|
+
return None
|
|
105
|
+
self.api[path] = value
|
|
106
|
+
self.size += size
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
def clean(self, value, key=None):
|
|
110
|
+
if isinstance(value, dict):
|
|
111
|
+
return {k: (None if not self.texts and k in _TEXT_KEYS and isinstance(v, (str, dict, list)) else self.clean(v, k))
|
|
112
|
+
for k, v in value.items()}
|
|
113
|
+
if isinstance(value, list):
|
|
114
|
+
return [self.clean(v, key) for v in value]
|
|
115
|
+
if isinstance(value, str) and not self.paths and key != 'url':
|
|
116
|
+
return self.hide(value)
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
def hide(self, text):
|
|
120
|
+
# a whole absolute path becomes its last component; known roots inside longer texts are replaced too
|
|
121
|
+
if re.fullmatch(r'(/|[A-Za-z]:[\\/])[^\n]*', text) and not text.startswith('/api/'):
|
|
122
|
+
return os.path.basename(text.rstrip('/\\')) or text
|
|
123
|
+
for root, name in self.roots:
|
|
124
|
+
if root in text:
|
|
125
|
+
text = text.replace(root, name)
|
|
126
|
+
return text
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build(catalog, key, texts=False, paths=False, now=None):
|
|
130
|
+
"""(file name, HTML bytes) of run `key`, or None when there is no such run."""
|
|
131
|
+
base = f'/api/runs/{key}'
|
|
132
|
+
run = server.api_get(catalog, base)
|
|
133
|
+
if not isinstance(run, dict):
|
|
134
|
+
return None
|
|
135
|
+
snap = _Snapshot(catalog, key, texts, paths)
|
|
136
|
+
home = os.path.expanduser('~')
|
|
137
|
+
for root in (run.get('path'), run.get('repo')):
|
|
138
|
+
if isinstance(root, str) and root.startswith('/') and len(root) > 1:
|
|
139
|
+
snap.roots.append((root.rstrip('/'), os.path.basename(root.rstrip('/')) or root))
|
|
140
|
+
if home and home != '/':
|
|
141
|
+
snap.roots.append((home, '~'))
|
|
142
|
+
snap.roots.sort(key=lambda r: -len(r[0]))
|
|
143
|
+
summary = next((r for r in catalog.runs() if r.get('key') == key), None)
|
|
144
|
+
snap.api['/api/runs'] = {'runs': [snap.clean(summary)] if summary else []}
|
|
145
|
+
detail = snap.add(base, required=True)
|
|
146
|
+
tasks = [t for t in (detail.get('tasks') or []) if isinstance(t, dict) and t.get('id')]
|
|
147
|
+
for task in tasks:
|
|
148
|
+
snap.add(f"{base}/tasks/{_enc(task['id'])}")
|
|
149
|
+
names = sorted(set(_EXEC.findall(json.dumps(run))))
|
|
150
|
+
for name in names:
|
|
151
|
+
snap.add(f'{base}/executions/{name}')
|
|
152
|
+
reviews = [r.get('name') for r in (run.get('final_reviews') or []) if isinstance(r, dict) and r.get('name')]
|
|
153
|
+
for name in reviews:
|
|
154
|
+
snap.add(f'{base}/final-reviews/{_enc(name)}')
|
|
155
|
+
snap.add(f'{base}/files')
|
|
156
|
+
snap.add(f'{base}/findings')
|
|
157
|
+
fractal = run.get('fractal') or {}
|
|
158
|
+
if fractal.get('enabled') and fractal.get('available'):
|
|
159
|
+
for task in tasks:
|
|
160
|
+
tree = snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal")
|
|
161
|
+
for node in (tree or {}).get('nodes') or []:
|
|
162
|
+
if isinstance(node, dict) and node.get('id') and not node.get('reserved'):
|
|
163
|
+
snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal/nodes/{_enc(node['id'])}")
|
|
164
|
+
if texts and node.get('has_diff'):
|
|
165
|
+
snap.add(f"{base}/tasks/{_enc(task['id'])}/fractal/nodes/{_enc(node['id'])}/diff", cap=DIFF_CAP)
|
|
166
|
+
if texts:
|
|
167
|
+
# every diff a page of this run can open: each task's files and each review's, the final reviews'
|
|
168
|
+
for task in tasks:
|
|
169
|
+
first = snap.add(f"{base}/tasks/{_enc(task['id'])}/diff", cap=DIFF_CAP)
|
|
170
|
+
for source in (first or {}).get('sources') or []:
|
|
171
|
+
url = source.get('url') if isinstance(source, dict) else None
|
|
172
|
+
if isinstance(url, str) and url.startswith(base + '/'):
|
|
173
|
+
snap.add(url, cap=DIFF_CAP)
|
|
174
|
+
for name in reviews:
|
|
175
|
+
snap.add(f'{base}/final-reviews/{_enc(name)}/diff', cap=DIFF_CAP)
|
|
176
|
+
# what each dwarf and reviewer said and ran: its conversation is all text
|
|
177
|
+
for name in names:
|
|
178
|
+
if name.startswith(('dwarf-', 'qa-')):
|
|
179
|
+
snap.add(f'{base}/executions/{name}/transcript', cap=TRANSCRIPT_CAP)
|
|
180
|
+
meta = {'format': 'forge-ui-export', 'version': 1, 'app': __version__, 'key': key,
|
|
181
|
+
'generated': now if now is not None else time.time(), 'texts': bool(texts), 'paths': bool(paths),
|
|
182
|
+
'omitted': snap.omitted}
|
|
183
|
+
# the title follows the same rules as the data: no local paths unless chosen
|
|
184
|
+
page = _page({'meta': meta, 'api': snap.api}, snap.clean({'goal': run.get('goal'), 'run_id': run.get('run_id')}))
|
|
185
|
+
stem = re.sub(r'[^A-Za-z0-9._-]+', '-', str(run.get('run_id') or key)).strip('.-')[:80] or key
|
|
186
|
+
return f'forge-run-{stem}.html', page.encode('utf-8')
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _page(snapshot, run):
|
|
190
|
+
index = (STATIC / 'index.html').read_text(encoding='utf-8')
|
|
191
|
+
importmap = re.search(r'<script type="importmap">(.*?)</script>', index, re.DOTALL).group(1)
|
|
192
|
+
body_tag, body = re.search(r'(<body[^>]*>)(.*)</body>', index, re.DOTALL).groups()
|
|
193
|
+
body = re.sub(r'\s*<script src="[^"]*"></script>', '', body) # the page's own scripts are inlined below
|
|
194
|
+
css = (STATIC / 'style.css').read_text(encoding='utf-8')
|
|
195
|
+
# the engraving face travels inside the file
|
|
196
|
+
css = re.sub(r'url\(([A-Za-z0-9_.-]+\.woff2)\)', lambda m: 'url(data:font/woff2;base64,%s)' % base64.b64encode((STATIC / m.group(1)).read_bytes()).decode('ascii'), css)
|
|
197
|
+
scripts = [_script_safe((STATIC / name).read_text(encoding='utf-8')) for name in SCRIPTS if (STATIC / name).is_file()]
|
|
198
|
+
data = json.dumps(snapshot, ensure_ascii=False, separators=(',', ':')).replace('<', '\\u003c').replace('>', '\\u003e').replace('&', '\\u0026')
|
|
199
|
+
stage = _script_safe(bundle())
|
|
200
|
+
icon = 'data:image/svg+xml;base64,' + base64.b64encode((STATIC / 'favicon.svg').read_bytes()).decode('ascii')
|
|
201
|
+
csp = ("default-src 'none'; script-src " + ' '.join([_sha(importmap)] + [_sha(s) for s in scripts]) + f" blob: {CDN}; "
|
|
202
|
+
f"style-src {_sha(css)}; img-src data: blob:; font-src data:; connect-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'")
|
|
203
|
+
title = 'forge-ui · ' + re.sub(r'[<>&"]', '', str(run.get('goal') or run.get('run_id') or 'run').split('\n')[0])[:120]
|
|
204
|
+
return ''.join([
|
|
205
|
+
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n',
|
|
206
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">\n<meta name="color-scheme" content="light dark">\n',
|
|
207
|
+
'<meta name="referrer" content="no-referrer">\n',
|
|
208
|
+
f'<meta http-equiv="Content-Security-Policy" content="{csp}">\n',
|
|
209
|
+
f'<title>{title}</title>\n<link rel="icon" href="{icon}">\n',
|
|
210
|
+
f'<script type="importmap">{importmap}</script>\n<style>{css}</style>\n</head>\n{body_tag}{body}',
|
|
211
|
+
f'<script type="application/json" id="forge-snapshot">{data}</script>\n',
|
|
212
|
+
f'<script type="text/plain" id="forge-stage">{stage}</script>\n',
|
|
213
|
+
''.join(f'<script>{s}</script>\n' for s in scripts),
|
|
214
|
+
'</body>\n</html>\n'])
|