@giamme/forge 0.9.0 → 0.11.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 +56 -0
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/artifacts.md +28 -2
- package/docs/commands.md +55 -0
- package/docs/dataset.md +22 -0
- package/docs/decomposed-runs.md +54 -2
- package/docs/forge-ui.md +48 -7
- package/docs/memory.md +4 -2
- package/docs/reviews.md +87 -2
- package/forge +31 -1
- package/package.json +1 -1
- package/references/dataset.md +103 -4
- package/references/decompose.md +41 -5
- package/references/harnesses.md +43 -1
- package/references/ui.md +31 -6
- package/scripts/forge-config.py +350 -0
- package/scripts/forge-control.py +649 -0
- package/scripts/forge-dispatch.sh +235 -121
- package/scripts/forge-issues.py +1134 -0
- package/scripts/forge-lib-plans.sh +6 -2
- package/scripts/forge-lib-review.sh +158 -3
- package/scripts/forge-lib-task.sh +358 -41
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +99 -13
- package/scripts/forge-prompt.py +61 -4
- package/scripts/forge-route.py +245 -28
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +19 -1
- package/scripts/forge-runtime.py +47 -1
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge_dataset/__init__.py +84 -8
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/cli.py +44 -1
- package/scripts/forge_dataset/export.py +36 -3
- package/scripts/forge_dataset/forecast.py +355 -0
- package/scripts/forge_dataset/harvest.py +45 -2
- package/scripts/forge_dataset/importer.py +431 -17
- package/scripts/forge_dataset/reviewers.py +145 -0
- package/scripts/forge_dataset/routing.py +1 -0
- package/scripts/forge_jev/__init__.py +7 -3
- package/scripts/forge_registry.py +183 -26
- package/ui/README.md +41 -7
- package/ui/docs/api.md +259 -21
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +587 -0
- package/ui/forge_ui/export.py +17 -3
- package/ui/forge_ui/reader.py +210 -12
- package/ui/forge_ui/server.py +125 -4
- package/ui/forge_ui/static/app.js +104 -18
- package/ui/forge_ui/static/cloud3d.js +4 -1
- package/ui/forge_ui/static/cloudmodel.js +1 -1
- package/ui/forge_ui/static/cloudui.js +13 -4
- package/ui/forge_ui/static/controls.js +619 -0
- package/ui/forge_ui/static/findingsview.js +438 -0
- package/ui/forge_ui/static/graph.js +6 -5
- package/ui/forge_ui/static/index.html +7 -1
- package/ui/forge_ui/static/leaderboard.js +18 -0
- package/ui/forge_ui/static/modelstats.js +19 -1
- package/ui/forge_ui/static/panel.js +76 -14
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +128 -13
- package/ui/forge_ui/static/scene3d.js +32 -15
- package/ui/forge_ui/static/settings.js +399 -0
- package/ui/forge_ui/static/style.css +184 -5
- package/ui/forge_ui/static/uikit.js +161 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""forge config <command>: one view of every config source, and safe edits for the per-repo ones.
|
|
3
|
+
|
|
4
|
+
show [--repo DIR] [--json] registry, Jev, the repo's .forge files, dataset and FORGE_* env
|
|
5
|
+
set <key> <value> --repo DIR [--json] repo.verify | repo.setup | repo.verify-subset | jev.enabled
|
|
6
|
+
unset <key> --repo DIR [--json] repo.verify | repo.setup | repo.verify-subset
|
|
7
|
+
|
|
8
|
+
A value of `-` reads stdin. repo.verify-subset must contain `{files}`. jev.enabled true|false is
|
|
9
|
+
delegated to `forge jev enable|disable`. The registry is read-only here: the skill dir is often a
|
|
10
|
+
live symlink to a checkout, so an edit would change runs already in progress; edit the checkout.
|
|
11
|
+
|
|
12
|
+
Only the three per-repo files are ever read for content; cloudflare.env never is. Values of FORGE_*
|
|
13
|
+
variables whose name contains TOKEN, KEY, SECRET or PASSWORD show as "<set>".
|
|
14
|
+
|
|
15
|
+
Exit codes: 0 ok, 2 usage or invalid input (including an unsafe path); any other code is the jev
|
|
16
|
+
CLI's own, passed through.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
import stat
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
SKILL = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
|
|
29
|
+
sys.path.insert(0, os.path.join(SKILL, 'scripts'))
|
|
30
|
+
|
|
31
|
+
REPO_FILES = ('verify', 'setup', 'verify-subset')
|
|
32
|
+
KEYS = tuple('repo.' + n for n in REPO_FILES) + ('jev.enabled',)
|
|
33
|
+
CONTENT_MAX = 64 * 1024
|
|
34
|
+
SECRET_WORDS = ('TOKEN', 'KEY', 'SECRET', 'PASSWORD')
|
|
35
|
+
REGISTRY_NOTE = ('read-only here: the skill dir is often a live symlink to a checkout, so an edit '
|
|
36
|
+
'changes runs already in progress; edit registry.tsv in the checkout instead')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Invalid(Exception):
|
|
40
|
+
"""Usage or invalid input, an unsafe path included: exit 2."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def git_top(path):
|
|
44
|
+
try:
|
|
45
|
+
out = subprocess.run(['git', '-C', path, 'rev-parse', '--show-toplevel'],
|
|
46
|
+
capture_output=True, text=True, timeout=20)
|
|
47
|
+
except (OSError, subprocess.SubprocessError):
|
|
48
|
+
return None
|
|
49
|
+
top = out.stdout.strip()
|
|
50
|
+
return os.path.realpath(top) if out.returncode == 0 and top else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def repo_root(arg, required):
|
|
54
|
+
"""The git top level for --repo (which must be exactly that), or the cwd's when not required."""
|
|
55
|
+
if arg is None:
|
|
56
|
+
return None if required else git_top(os.getcwd())
|
|
57
|
+
if not os.path.isdir(arg):
|
|
58
|
+
raise Invalid('--repo %s is not a directory' % arg)
|
|
59
|
+
top = git_top(arg)
|
|
60
|
+
if top is None or top != os.path.realpath(arg):
|
|
61
|
+
raise Invalid('--repo %s is not a git top level' % arg)
|
|
62
|
+
return top
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def forge_dir(root):
|
|
66
|
+
"""<root>/.forge when it is a real directory, None when absent; anything else is unsafe."""
|
|
67
|
+
path = os.path.join(root, '.forge')
|
|
68
|
+
try:
|
|
69
|
+
st = os.lstat(path)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return None
|
|
72
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
73
|
+
raise Invalid('%s must be a real directory, not a symlink or file' % path)
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def safe_target(root, name):
|
|
78
|
+
"""(.forge dir or None, target path); refuses a symlinked dir and a non-regular target."""
|
|
79
|
+
d = forge_dir(root)
|
|
80
|
+
target = os.path.join(root, '.forge', name)
|
|
81
|
+
if d is not None:
|
|
82
|
+
try:
|
|
83
|
+
st = os.lstat(target)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return d, target
|
|
86
|
+
if not stat.S_ISREG(st.st_mode):
|
|
87
|
+
raise Invalid('%s must be a regular file or absent (no symlink, FIFO or directory)' % target)
|
|
88
|
+
return d, target
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_small(path):
|
|
92
|
+
"""(content, note) of a regular file; content None with a note when it must not be read."""
|
|
93
|
+
try:
|
|
94
|
+
st = os.lstat(path)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return None, None
|
|
97
|
+
if not stat.S_ISREG(st.st_mode):
|
|
98
|
+
return None, 'not a regular file'
|
|
99
|
+
if st.st_size >= CONTENT_MAX:
|
|
100
|
+
return None, 'larger than 64 KiB'
|
|
101
|
+
try:
|
|
102
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_NONBLOCK', 0)
|
|
103
|
+
with os.fdopen(os.open(path, flags), 'rb') as handle:
|
|
104
|
+
data = handle.read(CONTENT_MAX + 1)
|
|
105
|
+
except OSError as err:
|
|
106
|
+
return None, 'unreadable: %s' % err.strerror
|
|
107
|
+
if len(data) >= CONTENT_MAX:
|
|
108
|
+
return None, 'larger than 64 KiB'
|
|
109
|
+
return data.decode('utf-8', 'replace'), None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def open_forge_dir(d):
|
|
113
|
+
"""Create .forge when absent and open it without following a symlink; callers write through the fd."""
|
|
114
|
+
try:
|
|
115
|
+
os.mkdir(d)
|
|
116
|
+
except FileExistsError:
|
|
117
|
+
pass
|
|
118
|
+
try:
|
|
119
|
+
return os.open(d, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
120
|
+
except OSError as err:
|
|
121
|
+
raise Invalid('%s must be a real directory, not a symlink or file (%s)' % (d, err.strerror))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def atomic_write(d, target, text):
|
|
125
|
+
dfd = open_forge_dir(d)
|
|
126
|
+
name = os.path.basename(target)
|
|
127
|
+
tmp = '.%s.%d.tmp' % (name, os.getpid())
|
|
128
|
+
try:
|
|
129
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=dfd)
|
|
130
|
+
try:
|
|
131
|
+
with os.fdopen(fd, 'w') as handle:
|
|
132
|
+
handle.write(text)
|
|
133
|
+
handle.flush()
|
|
134
|
+
os.fsync(handle.fileno())
|
|
135
|
+
os.replace(tmp, name, src_dir_fd=dfd, dst_dir_fd=dfd)
|
|
136
|
+
except BaseException:
|
|
137
|
+
try:
|
|
138
|
+
os.unlink(tmp, dir_fd=dfd)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
raise
|
|
142
|
+
finally:
|
|
143
|
+
os.close(dfd)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------------------------------ show
|
|
147
|
+
|
|
148
|
+
def registry_block():
|
|
149
|
+
override = os.environ.get('FORGE_REGISTRY')
|
|
150
|
+
path = override or os.path.join(SKILL, 'registry.tsv')
|
|
151
|
+
rows = []
|
|
152
|
+
try:
|
|
153
|
+
with open(path, encoding='utf-8', errors='replace') as handle:
|
|
154
|
+
for line in handle:
|
|
155
|
+
line = line.rstrip('\n')
|
|
156
|
+
fields = line.split('\t')
|
|
157
|
+
if line.startswith('#') or len(fields) < 3:
|
|
158
|
+
continue
|
|
159
|
+
fields += [''] * (5 - len(fields))
|
|
160
|
+
rows.append(dict(alias=fields[0], harness=fields[1], model=fields[2],
|
|
161
|
+
effort=fields[3], timeout=fields[4].strip()))
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
return dict(path=path, overridden=bool(override), rows=rows, editable=False, reason=REGISTRY_NOTE)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def jev_block():
|
|
168
|
+
from forge_jev import config_path, load_config
|
|
169
|
+
path = config_path()
|
|
170
|
+
return dict(path=str(path), exists=path.is_file(), enabled=bool(load_config().get('enabled')))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def repo_block(root):
|
|
174
|
+
files = {}
|
|
175
|
+
for name in REPO_FILES:
|
|
176
|
+
path = os.path.join(root, '.forge', name)
|
|
177
|
+
if forge_dir_ok(root):
|
|
178
|
+
content, note = read_small(path)
|
|
179
|
+
entry = dict(exists=content is not None or note is not None, content=content)
|
|
180
|
+
else:
|
|
181
|
+
# forge itself follows a symlinked .forge; say the file is there without reading through it
|
|
182
|
+
note = '.forge is not a real directory; contents not read'
|
|
183
|
+
entry = dict(exists=os.path.exists(path), content=None)
|
|
184
|
+
if note:
|
|
185
|
+
entry['note'] = note
|
|
186
|
+
files[name] = entry
|
|
187
|
+
return dict(path=root, files=files)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def forge_dir_ok(root):
|
|
191
|
+
try:
|
|
192
|
+
return forge_dir(root) is not None
|
|
193
|
+
except Invalid:
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def dataset_block():
|
|
198
|
+
# Mirrors the dataset package's data_home(), dataset_dir() and enabled() without importing it:
|
|
199
|
+
# outside its package the dataset is only written to, through capture (tests/test_dataset.py).
|
|
200
|
+
explicit = os.environ.get('FORGE_DATA_HOME')
|
|
201
|
+
if explicit:
|
|
202
|
+
home = Path(explicit).expanduser()
|
|
203
|
+
else:
|
|
204
|
+
value = os.environ.get('XDG_DATA_HOME') or '' # an empty or relative value counts as unset
|
|
205
|
+
base = Path(value).expanduser() if value else None
|
|
206
|
+
if base is None or not base.is_absolute():
|
|
207
|
+
base = Path.home() / '.local' / 'share'
|
|
208
|
+
home = base / 'forge'
|
|
209
|
+
enabled = os.environ.get('FORGE_DATASET', 'on').strip().lower() not in ('off', '0', 'no', 'false')
|
|
210
|
+
return dict(path=str(home / 'dataset'), enabled=enabled)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def env_block():
|
|
214
|
+
out = {}
|
|
215
|
+
for name in sorted(os.environ):
|
|
216
|
+
if name.startswith('FORGE_'):
|
|
217
|
+
out[name] = '<set>' if any(w in name for w in SECRET_WORDS) else os.environ[name]
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_show(args):
|
|
222
|
+
root = repo_root(args.repo, required=False)
|
|
223
|
+
data = dict(registry=registry_block(), jev=jev_block())
|
|
224
|
+
if root:
|
|
225
|
+
data['repo'] = repo_block(root)
|
|
226
|
+
data['dataset'] = dataset_block()
|
|
227
|
+
data['env'] = env_block()
|
|
228
|
+
if args.json:
|
|
229
|
+
print(json.dumps(data, indent=2))
|
|
230
|
+
return 0
|
|
231
|
+
reg = data['registry']
|
|
232
|
+
print('registry: %s%s (%d rows, read-only)' % (reg['path'], ' [FORGE_REGISTRY]' if reg['overridden'] else '',
|
|
233
|
+
len(reg['rows'])))
|
|
234
|
+
for row in reg['rows']:
|
|
235
|
+
print(' %-10s %-10s %s %s %s' % (row['alias'], row['harness'], row['model'], row['effort'], row['timeout']))
|
|
236
|
+
jev = data['jev']
|
|
237
|
+
print('jev: %s (%s, %s)' % ('enabled' if jev['enabled'] else 'disabled', jev['path'],
|
|
238
|
+
'present' if jev['exists'] else 'absent'))
|
|
239
|
+
if root:
|
|
240
|
+
print('repo: %s' % root)
|
|
241
|
+
for name, entry in data['repo']['files'].items():
|
|
242
|
+
shown = entry['content'].strip() if entry['content'] is not None else \
|
|
243
|
+
('(%s)' % entry['note'] if entry.get('note') else '(none)')
|
|
244
|
+
print(' .forge/%s: %s' % (name, shown))
|
|
245
|
+
print('dataset: %s (%s)' % (data['dataset']['path'], 'on' if data['dataset']['enabled'] else 'off'))
|
|
246
|
+
for name, value in data['env'].items():
|
|
247
|
+
print('%s=%s' % (name, value))
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ----------------------------------------------------------------------------------- set / unset
|
|
252
|
+
|
|
253
|
+
def check_key(key):
|
|
254
|
+
if key not in KEYS:
|
|
255
|
+
raise Invalid('unknown key %r; valid keys: %s' % (key, ', '.join(KEYS)))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def emit(args, key, previous, value):
|
|
259
|
+
if args.json:
|
|
260
|
+
print(json.dumps(dict(ok=True, key=key, previous=previous, value=value)))
|
|
261
|
+
else:
|
|
262
|
+
print('%s: %s' % (key, 'unset' if value is None else 'set'))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_set(args):
|
|
266
|
+
check_key(args.key)
|
|
267
|
+
if args.key == 'jev.enabled':
|
|
268
|
+
return set_jev(args)
|
|
269
|
+
root = repo_root(args.repo, required=True)
|
|
270
|
+
if root is None:
|
|
271
|
+
raise Invalid('--repo DIR is required for %s' % args.key)
|
|
272
|
+
value = sys.stdin.read() if args.value == '-' else args.value
|
|
273
|
+
name = args.key[len('repo.'):]
|
|
274
|
+
if not value.strip():
|
|
275
|
+
raise Invalid('empty value; use `forge config unset %s` to remove it' % args.key)
|
|
276
|
+
if name == 'verify-subset' and '{files}' not in value:
|
|
277
|
+
raise Invalid('repo.verify-subset must contain {files}')
|
|
278
|
+
d, target = safe_target(root, name)
|
|
279
|
+
previous = read_small(target)[0] if d else None
|
|
280
|
+
if not value.endswith('\n'):
|
|
281
|
+
value += '\n'
|
|
282
|
+
atomic_write(os.path.join(root, '.forge'), target, value)
|
|
283
|
+
emit(args, args.key, previous, value)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_unset(args):
|
|
288
|
+
check_key(args.key)
|
|
289
|
+
if args.key == 'jev.enabled':
|
|
290
|
+
raise Invalid('jev.enabled cannot be unset; use `set jev.enabled true|false`')
|
|
291
|
+
root = repo_root(args.repo, required=True)
|
|
292
|
+
if root is None:
|
|
293
|
+
raise Invalid('--repo DIR is required for %s' % args.key)
|
|
294
|
+
d, target = safe_target(root, args.key[len('repo.'):])
|
|
295
|
+
previous = read_small(target)[0] if d else None
|
|
296
|
+
if d and os.path.lexists(target):
|
|
297
|
+
dfd = open_forge_dir(d)
|
|
298
|
+
try:
|
|
299
|
+
os.unlink(os.path.basename(target), dir_fd=dfd)
|
|
300
|
+
finally:
|
|
301
|
+
os.close(dfd)
|
|
302
|
+
emit(args, args.key, previous, None)
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def set_jev(args):
|
|
307
|
+
if args.value not in ('true', 'false'):
|
|
308
|
+
raise Invalid('jev.enabled takes true or false')
|
|
309
|
+
previous = jev_block()['enabled']
|
|
310
|
+
verb = 'enable' if args.value == 'true' else 'disable'
|
|
311
|
+
run = subprocess.run([sys.executable, os.path.join(SKILL, 'scripts', 'forge-jev.py'), verb],
|
|
312
|
+
stdout=subprocess.DEVNULL, stderr=None)
|
|
313
|
+
if run.returncode != 0:
|
|
314
|
+
return run.returncode
|
|
315
|
+
emit(args, args.key, previous, args.value == 'true')
|
|
316
|
+
return 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def parser():
|
|
320
|
+
p = argparse.ArgumentParser(prog='forge config', description=__doc__.split('\n\n')[0])
|
|
321
|
+
sub = p.add_subparsers(dest='cmd', required=True)
|
|
322
|
+
s = sub.add_parser('show')
|
|
323
|
+
s.add_argument('--repo')
|
|
324
|
+
s.add_argument('--json', action='store_true')
|
|
325
|
+
s.set_defaults(fn=cmd_show)
|
|
326
|
+
s = sub.add_parser('set')
|
|
327
|
+
s.add_argument('key')
|
|
328
|
+
s.add_argument('value')
|
|
329
|
+
s.add_argument('--repo')
|
|
330
|
+
s.add_argument('--json', action='store_true')
|
|
331
|
+
s.set_defaults(fn=cmd_set)
|
|
332
|
+
s = sub.add_parser('unset')
|
|
333
|
+
s.add_argument('key')
|
|
334
|
+
s.add_argument('--repo')
|
|
335
|
+
s.add_argument('--json', action='store_true')
|
|
336
|
+
s.set_defaults(fn=cmd_unset)
|
|
337
|
+
return p
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv=None):
|
|
341
|
+
args = parser().parse_args(argv)
|
|
342
|
+
try:
|
|
343
|
+
return args.fn(args)
|
|
344
|
+
except Invalid as err:
|
|
345
|
+
print('forge config: %s' % err, file=sys.stderr)
|
|
346
|
+
return 2
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == '__main__':
|
|
350
|
+
sys.exit(main())
|