@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,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""forge version [--check]: which forge this is, and whether a newer release exists.
|
|
3
|
+
|
|
4
|
+
`forge version` prints the checkout's VERSION, commit and tracked upstream, plus the newest
|
|
5
|
+
release seen by the last check. `--check` asks the upstream now.
|
|
6
|
+
|
|
7
|
+
`notice` is what the runners call when a solo run, `plan` or `run` ends: at most once a day it
|
|
8
|
+
lists the upstream's release tags (`git ls-remote`, short timeout) and, when one is newer than
|
|
9
|
+
VERSION, prints a single line saying so. It is silent offline, when the checkout has no
|
|
10
|
+
upstream, in CI (`CI` set) and when `FORGE_UPDATE_CHECK=off`, and it always exits 0: it never
|
|
11
|
+
changes a run's outcome. The last check is cached in
|
|
12
|
+
`${XDG_STATE_HOME:-~/.local/state}/forge/update-check.json`.
|
|
13
|
+
|
|
14
|
+
An npm installation (the package sits under a `node_modules` folder, the same rule as
|
|
15
|
+
forge-update.sh) has no git upstream: there the newest release is the package's `latest` on the
|
|
16
|
+
npm registry (`npm_config_registry` when set, as npm reads it), and the update command is npm's.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
import time
|
|
28
|
+
import urllib.parse
|
|
29
|
+
import urllib.request
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
33
|
+
DAY_S = 24 * 3600
|
|
34
|
+
TIMEOUT_S = 5
|
|
35
|
+
_TAG = re.compile(r'^refs/tags/v(\d+(?:\.\d+)*)$')
|
|
36
|
+
_OFF = ('off', '0', 'false', 'no')
|
|
37
|
+
NPM_PACKAGE = '@giamme/forge'
|
|
38
|
+
NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def state_file() -> Path:
|
|
42
|
+
base = os.environ.get('XDG_STATE_HOME', '')
|
|
43
|
+
if not os.path.isabs(base): # unset, empty or relative: the XDG default
|
|
44
|
+
base = str(Path.home() / '.local/state')
|
|
45
|
+
return Path(base) / 'forge' / 'update-check.json'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _git(source: Path, *args, timeout=TIMEOUT_S):
|
|
49
|
+
env = dict(os.environ, GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='', SSH_ASKPASS='',
|
|
50
|
+
GIT_SSH_COMMAND=os.environ.get('GIT_SSH_COMMAND') or 'ssh -o BatchMode=yes')
|
|
51
|
+
try:
|
|
52
|
+
out = subprocess.run(['git', '-C', str(source), *args], capture_output=True, text=True,
|
|
53
|
+
stdin=subprocess.DEVNULL, env=env, timeout=timeout)
|
|
54
|
+
except (OSError, subprocess.SubprocessError):
|
|
55
|
+
return None
|
|
56
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _key(version: str):
|
|
60
|
+
return tuple(int(part) for part in version.split('.'))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def newer(candidate: str | None, current: str | None) -> bool:
|
|
64
|
+
try:
|
|
65
|
+
a, b = _key(candidate), _key(current)
|
|
66
|
+
except (AttributeError, ValueError):
|
|
67
|
+
return False
|
|
68
|
+
width = max(len(a), len(b))
|
|
69
|
+
return a + (0,) * (width - len(a)) > b + (0,) * (width - len(b))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def local_version(source: Path) -> str | None:
|
|
73
|
+
try:
|
|
74
|
+
text = (source / 'VERSION').read_text(encoding='utf-8').strip()
|
|
75
|
+
except (OSError, UnicodeDecodeError):
|
|
76
|
+
return None
|
|
77
|
+
return text if re.fullmatch(r'\d+(?:\.\d+)*', text) else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def checkout(source: Path) -> dict:
|
|
81
|
+
"""The git facts of a forge checkout; empty when it is not one (a copied installation)."""
|
|
82
|
+
top = _git(source, 'rev-parse', '--show-toplevel')
|
|
83
|
+
if not top or Path(top).resolve() != source.resolve():
|
|
84
|
+
return {}
|
|
85
|
+
info = dict(commit=_git(source, 'rev-parse', '--short', 'HEAD'),
|
|
86
|
+
branch=_git(source, 'symbolic-ref', '--quiet', '--short', 'HEAD'))
|
|
87
|
+
remote = _git(source, 'config', '--get', f'branch.{info["branch"]}.remote') if info['branch'] else None
|
|
88
|
+
info['upstream'] = _git(source, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') \
|
|
89
|
+
if info['branch'] else None
|
|
90
|
+
info['remote'] = remote or ('origin' if _git(source, 'config', '--get', 'remote.origin.url') else None)
|
|
91
|
+
info['url'] = _git(source, 'config', '--get', f'remote.{info["remote"]}.url') if info['remote'] else None
|
|
92
|
+
return info
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def npm_install(source: Path) -> dict:
|
|
96
|
+
"""An npm installation's facts, like checkout()'s: empty when `source` is not under node_modules."""
|
|
97
|
+
if 'node_modules' not in source.resolve().parts:
|
|
98
|
+
return {}
|
|
99
|
+
try:
|
|
100
|
+
name = json.loads((source / 'package.json').read_text(encoding='utf-8')).get('name') or NPM_PACKAGE
|
|
101
|
+
except (OSError, ValueError, UnicodeDecodeError, AttributeError):
|
|
102
|
+
name = NPM_PACKAGE
|
|
103
|
+
registry = os.environ.get('npm_config_registry') or os.environ.get('NPM_CONFIG_REGISTRY') or NPM_REGISTRY
|
|
104
|
+
url = registry.rstrip('/') + '/' + urllib.parse.quote(name, safe='@') + '/latest'
|
|
105
|
+
return dict(npm=name, remote='npm', url=url)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def update_command(info: dict) -> str:
|
|
109
|
+
return f'npm install -g {info["npm"]}@latest && forge install' if info.get('npm') else 'forge update'
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def npm_latest(url: str) -> str | None:
|
|
113
|
+
"""The `latest` version on the registry, or None when it cannot be read in time."""
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={'Accept': 'application/json'}),
|
|
116
|
+
timeout=TIMEOUT_S) as reply:
|
|
117
|
+
version = json.loads(reply.read().decode('utf-8')).get('version')
|
|
118
|
+
except Exception: # offline, HTTP error, a proxy page instead of JSON: no answer, never a failure
|
|
119
|
+
return None
|
|
120
|
+
return version if isinstance(version, str) and re.fullmatch(r'\d+(?:\.\d+)*', version) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def latest_release(source: Path, remote: str) -> str | None:
|
|
124
|
+
"""The highest vX[.Y[.Z]] tag on the remote, or None when it cannot be reached in time."""
|
|
125
|
+
out = _git(source, 'ls-remote', '--tags', '--refs', '--', remote, 'v*')
|
|
126
|
+
if out is None:
|
|
127
|
+
return None
|
|
128
|
+
versions = [m.group(1) for m in (_TAG.match(line.split('\t')[-1]) for line in out.splitlines()) if m]
|
|
129
|
+
return max(versions, key=_key, default=None)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def read_cache() -> dict:
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(state_file().read_text(encoding='utf-8'))
|
|
135
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
136
|
+
return {}
|
|
137
|
+
return data if isinstance(data, dict) else {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_cache(data: dict) -> None:
|
|
141
|
+
path = state_file()
|
|
142
|
+
try:
|
|
143
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.update-check.')
|
|
145
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
|
146
|
+
json.dump(data, handle)
|
|
147
|
+
os.replace(tmp, path)
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check(source: Path, info: dict) -> dict:
|
|
153
|
+
"""Ask the upstream now and cache the answer. Offline keeps the last known release."""
|
|
154
|
+
cache = read_cache()
|
|
155
|
+
found = npm_latest(info['url']) if info.get('npm') else latest_release(source, info['remote'])
|
|
156
|
+
same = cache.get('url') == info['url']
|
|
157
|
+
entry = dict(checked=time.time(), url=info['url'],
|
|
158
|
+
latest=found if found else (cache.get('latest') if same else None), reached=found is not None)
|
|
159
|
+
write_cache(entry)
|
|
160
|
+
return entry
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def disabled() -> bool:
|
|
164
|
+
if os.environ.get('FORGE_UPDATE_CHECK', '').strip().lower() in _OFF:
|
|
165
|
+
return True
|
|
166
|
+
ci = os.environ.get('CI', '').strip().lower()
|
|
167
|
+
return bool(ci) and ci not in _OFF
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def available_line(latest: str, current: str, info: dict | None = None) -> str:
|
|
171
|
+
return f'forge {latest} is available (you have {current}): {update_command(info or {})}'
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def notice(source: Path) -> None:
|
|
175
|
+
if disabled():
|
|
176
|
+
return
|
|
177
|
+
current = local_version(source)
|
|
178
|
+
info = checkout(source) or npm_install(source)
|
|
179
|
+
if not current or not info.get('remote'):
|
|
180
|
+
return
|
|
181
|
+
cache = read_cache()
|
|
182
|
+
checked = cache.get('checked')
|
|
183
|
+
if isinstance(checked, (int, float)) and cache.get('url') == info['url'] and 0 <= time.time() - checked < DAY_S:
|
|
184
|
+
return # checked (and told) within the last day
|
|
185
|
+
entry = check(source, info)
|
|
186
|
+
if newer(entry['latest'], current):
|
|
187
|
+
print(available_line(entry['latest'], current, info))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def show(source: Path, ask: bool) -> int:
|
|
191
|
+
current = local_version(source)
|
|
192
|
+
print(f'forge {current or "unknown"}')
|
|
193
|
+
info = checkout(source) or npm_install(source)
|
|
194
|
+
if not info:
|
|
195
|
+
print(f'checkout: {source} (not a git checkout; update it with forge update --source <checkout>)')
|
|
196
|
+
return 0
|
|
197
|
+
if info.get('npm'):
|
|
198
|
+
print(f'installed: {source} (npm package {info["npm"]})')
|
|
199
|
+
print(f'upstream: npm registry ({info["url"]})')
|
|
200
|
+
else:
|
|
201
|
+
where = info['branch'] or 'detached HEAD'
|
|
202
|
+
print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
|
|
203
|
+
if info.get('remote'):
|
|
204
|
+
print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
|
|
205
|
+
else:
|
|
206
|
+
print('upstream: none')
|
|
207
|
+
entry = check(source, info) if ask and info.get('remote') else read_cache()
|
|
208
|
+
if ask and info.get('remote') and not entry.get('reached'):
|
|
209
|
+
print(f'latest: could not reach {info["remote"]}')
|
|
210
|
+
elif entry.get('latest') and entry.get('url') == info['url']:
|
|
211
|
+
when = time.strftime('%Y-%m-%d %H:%M', time.localtime(entry['checked'])) \
|
|
212
|
+
if isinstance(entry.get('checked'), (int, float)) else 'unknown'
|
|
213
|
+
print(f'latest: {entry["latest"]} (checked {when})')
|
|
214
|
+
if newer(entry['latest'], current):
|
|
215
|
+
print(available_line(entry['latest'], current, info))
|
|
216
|
+
else:
|
|
217
|
+
print('latest: not checked yet; run forge version --check')
|
|
218
|
+
if info.get('npm'):
|
|
219
|
+
print(f'update: {update_command(info)}')
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main(argv=None) -> int:
|
|
224
|
+
parser = argparse.ArgumentParser(prog='forge version', description='Show the forge version and check for a newer one.')
|
|
225
|
+
parser.add_argument('--check', action='store_true', help='ask the upstream for its newest release now')
|
|
226
|
+
parser.add_argument('--source', type=Path, default=SKILL, help=argparse.SUPPRESS)
|
|
227
|
+
parser.add_argument('mode', nargs='?', choices=['notice'], help=argparse.SUPPRESS)
|
|
228
|
+
args = parser.parse_args(argv)
|
|
229
|
+
source = args.source.resolve()
|
|
230
|
+
if args.mode == 'notice':
|
|
231
|
+
try:
|
|
232
|
+
notice(source)
|
|
233
|
+
except Exception: # never let the notice change a run's outcome
|
|
234
|
+
pass
|
|
235
|
+
return 0
|
|
236
|
+
return show(source, args.check)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == '__main__':
|
|
240
|
+
sys.exit(main())
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Forge's run dataset: the inputs and outcomes of every run, kept after run dirs are gone.
|
|
2
|
+
|
|
3
|
+
Run and plan directories hold nearly everything a decision model could learn from --
|
|
4
|
+
prompts, diffs, the resolved model of every dispatch, verdicts, tokens, Jev judgments --
|
|
5
|
+
but they live in /tmp and scratchpads and get deleted. The only durable record,
|
|
6
|
+
<repo>/.forge/ledger.tsv, keeps spec strings and verdicts and none of the inputs. This
|
|
7
|
+
package keeps a global, append-only copy of each run's raw events outside every repo:
|
|
8
|
+
|
|
9
|
+
${FORGE_DATA_HOME:-${XDG_DATA_HOME:-~/.local/share}/forge}/dataset/
|
|
10
|
+
dataset.sqlite events (rid-keyed, INSERT OR IGNORE) and repos
|
|
11
|
+
blobs/<aa>/<sha>.gz capped, gzipped text, addressed by the sha of the STORED bytes
|
|
12
|
+
errors.log capture failures
|
|
13
|
+
|
|
14
|
+
What it stores is raw state and outcomes, never training examples: an exporter derives
|
|
15
|
+
(state, question, label) rows per decision later, so a question nobody has thought of yet
|
|
16
|
+
can still be answered from old runs.
|
|
17
|
+
|
|
18
|
+
Capture is fail-open in the same way Jev is. Every entry point swallows its own errors
|
|
19
|
+
into errors.log, never prints, never raises, and bounds its wait on the database with the
|
|
20
|
+
SQLite busy timeout. It uses no signals, because Fractal captures from worker threads.
|
|
21
|
+
FORGE_DATASET=off turns it off.
|
|
22
|
+
|
|
23
|
+
Nothing that builds a prompt may ever read this store: a reviewer that could see earlier
|
|
24
|
+
runs' verdicts would no longer be independent.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import gzip
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import sqlite3
|
|
33
|
+
import subprocess
|
|
34
|
+
import time
|
|
35
|
+
import traceback
|
|
36
|
+
import uuid
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
import forge_registry
|
|
40
|
+
|
|
41
|
+
SCHEMA = 2
|
|
42
|
+
SCRIPTS = Path(__file__).resolve().parent.parent
|
|
43
|
+
SKILL = SCRIPTS.parent
|
|
44
|
+
BUSY_TIMEOUT_S = 1.0
|
|
45
|
+
ERRORS_MAX = 1 << 20
|
|
46
|
+
OFF = ('off', '0', 'no', 'false')
|
|
47
|
+
DRY = False # import --dry-run: build events and blob references, write nothing
|
|
48
|
+
|
|
49
|
+
# (head, tail) bytes kept per kind of content. A cut keeps both ends because the end is
|
|
50
|
+
# often what matters: a QA prompt ends with the diff, a log ends with the failure.
|
|
51
|
+
CAPS = {
|
|
52
|
+
'prompt': (256 << 10, 768 << 10),
|
|
53
|
+
'diff': (1536 << 10, 512 << 10),
|
|
54
|
+
'message': (64 << 10, 192 << 10),
|
|
55
|
+
'log': (16 << 10, 256 << 10),
|
|
56
|
+
'text': (64 << 10, 192 << 10),
|
|
57
|
+
'request': (768 << 10, 256 << 10),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
SCHEMA_SQL = """
|
|
61
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
62
|
+
rid TEXT PRIMARY KEY,
|
|
63
|
+
kind TEXT NOT NULL,
|
|
64
|
+
ts REAL NOT NULL,
|
|
65
|
+
src TEXT NOT NULL,
|
|
66
|
+
repo TEXT, run TEXT, run_id TEXT, mode TEXT, task TEXT, attempt INTEGER,
|
|
67
|
+
parent TEXT, inv TEXT, forge_version TEXT, registry_sha TEXT,
|
|
68
|
+
data TEXT NOT NULL, code_sha TEXT, forge_rev TEXT
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS events_run ON events(run, task);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS events_kind ON events(kind, ts);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS repos (
|
|
73
|
+
repo TEXT PRIMARY KEY, path TEXT, origin TEXT, first_seen REAL, last_seen REAL
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
EVENT_COLUMNS = ('rid', 'kind', 'ts', 'src', 'repo', 'run', 'run_id', 'mode', 'task',
|
|
79
|
+
'attempt', 'parent', 'inv', 'forge_version', 'registry_sha', 'data', 'code_sha', 'forge_rev')
|
|
80
|
+
# Columns added after schema 1, in order. connect() adds any a store is missing.
|
|
81
|
+
ADDED_COLUMNS = (('code_sha', 'TEXT'), ('forge_rev', 'TEXT'))
|
|
82
|
+
PROVENANCE = ('forge_version', 'registry_sha', 'code_sha', 'forge_rev')
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enabled() -> bool:
|
|
86
|
+
return os.environ.get('FORGE_DATASET', 'on').strip().lower() not in OFF
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def data_home() -> Path:
|
|
90
|
+
explicit = os.environ.get('FORGE_DATA_HOME')
|
|
91
|
+
if explicit:
|
|
92
|
+
return Path(explicit).expanduser()
|
|
93
|
+
base = os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local' / 'share')
|
|
94
|
+
return Path(base).expanduser() / 'forge'
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dataset_dir() -> Path:
|
|
98
|
+
return data_home() / 'dataset'
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def config_runs_dir() -> Path:
|
|
102
|
+
"""The runs registry (forge_registry.registry_dir): plan dirs per repo."""
|
|
103
|
+
return forge_registry.registry_dir()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log_error(where: str, error: BaseException | str) -> None:
|
|
107
|
+
"""Record a capture failure. Never raises: this is the last line of defence."""
|
|
108
|
+
try:
|
|
109
|
+
directory = dataset_dir()
|
|
110
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
path = directory / 'errors.log'
|
|
112
|
+
if path.exists() and path.stat().st_size > ERRORS_MAX:
|
|
113
|
+
path.replace(directory / 'errors.log.1')
|
|
114
|
+
if isinstance(error, BaseException):
|
|
115
|
+
detail = ''.join(traceback.format_exception_only(type(error), error)).strip()
|
|
116
|
+
frames = traceback.extract_tb(error.__traceback__)
|
|
117
|
+
if frames:
|
|
118
|
+
last = frames[-1]
|
|
119
|
+
detail += ' (%s:%s)' % (Path(last.filename).name, last.lineno)
|
|
120
|
+
else:
|
|
121
|
+
detail = str(error)
|
|
122
|
+
with path.open('a') as handle:
|
|
123
|
+
handle.write('%s\t%s\t%s\n' % (time.strftime('%Y-%m-%dT%H:%M:%S'), where,
|
|
124
|
+
detail.replace('\n', ' ')[:2000]))
|
|
125
|
+
path.chmod(0o600)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _private_dir(path: Path) -> None:
|
|
131
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
try:
|
|
133
|
+
path.chmod(0o700)
|
|
134
|
+
except OSError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def inside(path: Path, root: Path) -> bool:
|
|
139
|
+
try:
|
|
140
|
+
path.resolve().relative_to(root.resolve())
|
|
141
|
+
return True
|
|
142
|
+
except (ValueError, OSError):
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def location_refused(repo: str | Path | None) -> str | None:
|
|
147
|
+
"""Why the store must not be written for this repo, or None.
|
|
148
|
+
|
|
149
|
+
A store inside the repo would dirty the user's tree, and `integrate` refuses to run
|
|
150
|
+
on a dirty tree. Inside a forge worktree it would land in a task's diff.
|
|
151
|
+
"""
|
|
152
|
+
store = dataset_dir()
|
|
153
|
+
if '.forge-worktrees' in store.resolve().parts:
|
|
154
|
+
return 'the dataset dir is inside a forge worktree'
|
|
155
|
+
if repo and inside(store, Path(repo)):
|
|
156
|
+
return 'the dataset dir is inside the repository %s' % repo
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def connect() -> sqlite3.Connection:
|
|
161
|
+
directory = dataset_dir()
|
|
162
|
+
_private_dir(directory)
|
|
163
|
+
path = directory / 'dataset.sqlite'
|
|
164
|
+
new = not path.exists()
|
|
165
|
+
db = sqlite3.connect(str(path), timeout=BUSY_TIMEOUT_S, isolation_level=None)
|
|
166
|
+
if new:
|
|
167
|
+
try:
|
|
168
|
+
path.chmod(0o600)
|
|
169
|
+
except OSError:
|
|
170
|
+
pass
|
|
171
|
+
# Switching to WAL needs the file to itself, and SQLite does not wait on the busy
|
|
172
|
+
# timeout for it: two first writers to a brand-new store (threads of one plan's
|
|
173
|
+
# predictions) would see "database is locked" and drop an event. Only a store that is
|
|
174
|
+
# not WAL yet asks, and losing that race is harmless -- the winner makes it WAL, and
|
|
175
|
+
# every write below waits on the busy timeout either way.
|
|
176
|
+
try:
|
|
177
|
+
if str(db.execute('PRAGMA journal_mode').fetchone()[0]).lower() != 'wal':
|
|
178
|
+
db.execute('PRAGMA journal_mode=WAL')
|
|
179
|
+
except sqlite3.OperationalError:
|
|
180
|
+
pass
|
|
181
|
+
db.execute('PRAGMA synchronous=NORMAL')
|
|
182
|
+
db.executescript(SCHEMA_SQL)
|
|
183
|
+
db.execute("INSERT OR IGNORE INTO meta(key, value) VALUES ('schema', ?)", (str(SCHEMA),))
|
|
184
|
+
migrate(db)
|
|
185
|
+
return db
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _columns(db: sqlite3.Connection) -> set:
|
|
189
|
+
return {row[1] for row in db.execute('PRAGMA table_info(events)')}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def migrate(db: sqlite3.Connection) -> None:
|
|
193
|
+
"""Bring a schema-1 store up to date, once, under the write lock.
|
|
194
|
+
|
|
195
|
+
Concurrent first captures after an upgrade all try this: the column set is re-read
|
|
196
|
+
inside BEGIN IMMEDIATE, so exactly one of them alters the table and the others see
|
|
197
|
+
it done. ADD COLUMN never rewrites the table, so the lock is held for milliseconds.
|
|
198
|
+
The schema number moves last, so an interrupted migration is simply redone.
|
|
199
|
+
"""
|
|
200
|
+
if all(name in _columns(db) for name, _ in ADDED_COLUMNS):
|
|
201
|
+
return
|
|
202
|
+
db.execute('BEGIN IMMEDIATE')
|
|
203
|
+
try:
|
|
204
|
+
have = _columns(db)
|
|
205
|
+
for name, kind in ADDED_COLUMNS:
|
|
206
|
+
if name not in have:
|
|
207
|
+
db.execute('ALTER TABLE events ADD COLUMN %s %s' % (name, kind))
|
|
208
|
+
db.execute("INSERT OR REPLACE INTO meta(key, value) VALUES ('schema', ?)", (str(SCHEMA),))
|
|
209
|
+
db.execute('COMMIT')
|
|
210
|
+
except BaseException:
|
|
211
|
+
try:
|
|
212
|
+
db.execute('ROLLBACK')
|
|
213
|
+
except sqlite3.Error:
|
|
214
|
+
pass
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sha256(data: bytes) -> str:
|
|
219
|
+
return hashlib.sha256(data).hexdigest()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cut(data: bytes, kind: str) -> tuple[bytes, list | None]:
|
|
223
|
+
head, tail = CAPS.get(kind, CAPS['text'])
|
|
224
|
+
if len(data) <= head + tail:
|
|
225
|
+
return data, None
|
|
226
|
+
marker = b'\n[... forge-dataset: %d bytes cut ...]\n' % (len(data) - head - tail)
|
|
227
|
+
kept = data[:head] + marker + (data[-tail:] if tail else b'')
|
|
228
|
+
return kept, [head, len(data) - tail]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def put_blob(data: bytes | str, kind: str = 'text') -> dict:
|
|
232
|
+
"""Store content once, capped; return the reference an event carries.
|
|
233
|
+
|
|
234
|
+
The address is the sha of the stored bytes, and the original's sha and size travel
|
|
235
|
+
in the reference: two different originals can share a cut prefix, and the same
|
|
236
|
+
original cut under two caps must not collide.
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(data, str):
|
|
239
|
+
data = data.encode('utf-8', 'replace')
|
|
240
|
+
stored, span = cut(data, kind)
|
|
241
|
+
stored_sha = sha256(stored)
|
|
242
|
+
directory = dataset_dir() / 'blobs' / stored_sha[:2]
|
|
243
|
+
path = directory / (stored_sha + '.gz')
|
|
244
|
+
if not DRY and not path.exists():
|
|
245
|
+
_private_dir(dataset_dir() / 'blobs')
|
|
246
|
+
_private_dir(directory)
|
|
247
|
+
temporary = directory / ('.%s.%s' % (stored_sha, uuid.uuid4().hex))
|
|
248
|
+
with gzip.open(temporary, 'wb', compresslevel=6) as handle:
|
|
249
|
+
handle.write(stored)
|
|
250
|
+
temporary.chmod(0o600)
|
|
251
|
+
temporary.replace(path)
|
|
252
|
+
return dict(sha=stored_sha, original_sha=sha256(data) if span else stored_sha,
|
|
253
|
+
n=len(data), kept=len(stored), cut=span)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def put_file(path: str | Path, kind: str = 'text') -> dict | None:
|
|
257
|
+
path = Path(path)
|
|
258
|
+
try:
|
|
259
|
+
if not path.is_file():
|
|
260
|
+
return None
|
|
261
|
+
return put_blob(path.read_bytes(), kind)
|
|
262
|
+
except OSError:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def read_blob(stored_sha: str) -> bytes:
|
|
267
|
+
with gzip.open(dataset_dir() / 'blobs' / stored_sha[:2] / (stored_sha + '.gz'), 'rb') as handle:
|
|
268
|
+
return handle.read()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_PROVENANCE: dict | None = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def provenance() -> dict:
|
|
275
|
+
"""Which forge produced an event, so prompt, contract and registry drift is visible later.
|
|
276
|
+
|
|
277
|
+
forge_version is the release, which the dev checkout outlives by weeks of edits.
|
|
278
|
+
code_sha is the content of every file that shapes a run (scripts, SKILL.md, the
|
|
279
|
+
references the orchestrator follows, the effective registry), so two runs share it
|
|
280
|
+
only if they ran the same prompts and contracts. forge_rev is the checkout's commit,
|
|
281
|
+
a readable anchor for it; code_sha also covers uncommitted edits on top.
|
|
282
|
+
"""
|
|
283
|
+
global _PROVENANCE
|
|
284
|
+
if _PROVENANCE is None:
|
|
285
|
+
version = None
|
|
286
|
+
try:
|
|
287
|
+
version = (SKILL / 'VERSION').read_text().strip() or None
|
|
288
|
+
except OSError:
|
|
289
|
+
pass
|
|
290
|
+
registry = Path(os.environ.get('FORGE_REGISTRY') or str(SKILL / 'registry.tsv'))
|
|
291
|
+
try:
|
|
292
|
+
registry_bytes = registry.read_bytes()
|
|
293
|
+
except OSError:
|
|
294
|
+
registry_bytes = None
|
|
295
|
+
_PROVENANCE = dict(forge_version=version,
|
|
296
|
+
registry_sha=sha256(registry_bytes)[:12] if registry_bytes is not None else None,
|
|
297
|
+
code_sha=code_sha(registry_bytes), forge_rev=forge_rev())
|
|
298
|
+
return _PROVENANCE
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
CODE_FILES = ('SKILL.md', 'VERSION', 'forge')
|
|
302
|
+
CODE_TREES = ('scripts', 'references')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def code_sha(registry_bytes: bytes | None) -> str | None:
|
|
306
|
+
try:
|
|
307
|
+
digest = hashlib.sha256()
|
|
308
|
+
paths = [SKILL / name for name in CODE_FILES]
|
|
309
|
+
for tree in CODE_TREES:
|
|
310
|
+
paths.extend(p for p in (SKILL / tree).rglob('*') if p.is_file() and '__pycache__' not in p.parts)
|
|
311
|
+
for path in sorted(paths):
|
|
312
|
+
try:
|
|
313
|
+
content = path.read_bytes()
|
|
314
|
+
except OSError:
|
|
315
|
+
continue
|
|
316
|
+
digest.update(b'%s\0%s\n' % (path.relative_to(SKILL).as_posix().encode(), sha256(content).encode()))
|
|
317
|
+
digest.update(b'registry\0%s\n' % (sha256(registry_bytes).encode() if registry_bytes is not None else b'-'))
|
|
318
|
+
return digest.hexdigest()[:16]
|
|
319
|
+
except Exception:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def forge_rev() -> str | None:
|
|
324
|
+
"""HEAD of the forge checkout, or None for a copied skill dir. Read-only: no index refresh."""
|
|
325
|
+
if not (SKILL / '.git').exists():
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
result = subprocess.run(['git', '-C', str(SKILL), 'rev-parse', 'HEAD'], stdout=subprocess.PIPE,
|
|
329
|
+
stderr=subprocess.DEVNULL, timeout=2, env=dict(os.environ, GIT_OPTIONAL_LOCKS='0'))
|
|
330
|
+
except (OSError, subprocess.SubprocessError):
|
|
331
|
+
return None
|
|
332
|
+
rev = result.stdout.decode('ascii', 'replace').strip()
|
|
333
|
+
return rev if result.returncode == 0 and len(rev) >= 40 else None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def repo_id(path: str | Path) -> str:
|
|
337
|
+
"""Same scheme as the runs registry, so the two join on one key."""
|
|
338
|
+
return forge_registry.repo_key(path)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def git_origin(repo: str | Path) -> str | None:
|
|
342
|
+
"""The origin URL, read from .git/config without running git."""
|
|
343
|
+
try:
|
|
344
|
+
config = Path(repo) / '.git' / 'config'
|
|
345
|
+
if not config.is_file():
|
|
346
|
+
return None
|
|
347
|
+
section = None
|
|
348
|
+
for line in config.read_text(errors='replace').splitlines():
|
|
349
|
+
stripped = line.strip()
|
|
350
|
+
if stripped.startswith('['):
|
|
351
|
+
section = stripped
|
|
352
|
+
elif section == '[remote "origin"]' and stripped.startswith('url'):
|
|
353
|
+
return stripped.split('=', 1)[1].strip()
|
|
354
|
+
except OSError:
|
|
355
|
+
return None
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def register_repo(db: sqlite3.Connection, path: str | Path | None) -> str | None:
|
|
360
|
+
if not path:
|
|
361
|
+
return None
|
|
362
|
+
key = repo_id(path)
|
|
363
|
+
now = time.time()
|
|
364
|
+
db.execute('INSERT OR IGNORE INTO repos(repo, path, origin, first_seen, last_seen) '
|
|
365
|
+
'VALUES (?, ?, ?, ?, ?)', (key, str(Path(path).resolve()), git_origin(path), now, now))
|
|
366
|
+
db.execute('UPDATE repos SET last_seen = ? WHERE repo = ?', (now, key))
|
|
367
|
+
return key
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def insert(db: sqlite3.Connection, event: dict) -> bool:
|
|
371
|
+
"""Append one event. The first writer of a rid wins, so import and live never duplicate."""
|
|
372
|
+
row = dict(event)
|
|
373
|
+
row.setdefault('src', 'live')
|
|
374
|
+
if row['src'] != 'import':
|
|
375
|
+
# An imported run was produced by whatever forge ran it, not by the importer, so its
|
|
376
|
+
# provenance stays unknown. Rows imported before schema 2 carry the importer's
|
|
377
|
+
# version: readers treat forge_version/registry_sha on src='import' as unknown.
|
|
378
|
+
row.update({k: v for k, v in provenance().items() if row.get(k) is None})
|
|
379
|
+
row.setdefault('ts', time.time())
|
|
380
|
+
row['data'] = json.dumps(row.get('data') or {}, sort_keys=True, default=str)
|
|
381
|
+
values = [row.get(column) for column in EVENT_COLUMNS]
|
|
382
|
+
cursor = db.execute('INSERT OR IGNORE INTO events(%s) VALUES (%s)'
|
|
383
|
+
% (', '.join(EVENT_COLUMNS), ', '.join('?' * len(EVENT_COLUMNS))), values)
|
|
384
|
+
return cursor.rowcount == 1
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def record(events: list[dict], repo: str | Path | None = None, where: str = 'record') -> int:
|
|
388
|
+
"""Write events in one transaction. Fail-open: returns how many were new, 0 on error."""
|
|
389
|
+
if not enabled() or not events:
|
|
390
|
+
return 0
|
|
391
|
+
try:
|
|
392
|
+
# Refused silently: errors.log lives in the same refused place.
|
|
393
|
+
if location_refused(repo):
|
|
394
|
+
return 0
|
|
395
|
+
# Fingerprinting the code reads every shipped file and runs git once per process:
|
|
396
|
+
# done under the write lock, it held every other writer past its busy timeout.
|
|
397
|
+
provenance()
|
|
398
|
+
db = connect()
|
|
399
|
+
try:
|
|
400
|
+
db.execute('BEGIN IMMEDIATE')
|
|
401
|
+
key = register_repo(db, repo) if repo else None
|
|
402
|
+
added = 0
|
|
403
|
+
for event in events:
|
|
404
|
+
if key and not event.get('repo'):
|
|
405
|
+
event['repo'] = key
|
|
406
|
+
added += insert(db, event)
|
|
407
|
+
db.execute('COMMIT')
|
|
408
|
+
return added
|
|
409
|
+
except BaseException:
|
|
410
|
+
try:
|
|
411
|
+
db.execute('ROLLBACK')
|
|
412
|
+
except sqlite3.Error:
|
|
413
|
+
pass
|
|
414
|
+
raise
|
|
415
|
+
finally:
|
|
416
|
+
db.close()
|
|
417
|
+
except Exception as error:
|
|
418
|
+
log_error(where, error)
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def create_once(path: Path, text: str) -> str:
|
|
423
|
+
"""Write `text` to `path` unless it exists; return whichever content won.
|
|
424
|
+
|
|
425
|
+
O_EXCL makes two concurrent first captures agree on one value instead of each
|
|
426
|
+
replacing the other's.
|
|
427
|
+
"""
|
|
428
|
+
try:
|
|
429
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
430
|
+
except FileExistsError:
|
|
431
|
+
for _ in range(20):
|
|
432
|
+
existing = path.read_text().strip()
|
|
433
|
+
if existing:
|
|
434
|
+
return existing
|
|
435
|
+
time.sleep(0.01)
|
|
436
|
+
return path.read_text().strip() or text
|
|
437
|
+
with os.fdopen(fd, 'w') as handle:
|
|
438
|
+
handle.write(text + '\n')
|
|
439
|
+
return text
|