@giamme/forge 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/SKILL.md +19 -1
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +17 -2
  5. package/docs/commands.md +50 -0
  6. package/docs/dataset.md +9 -0
  7. package/docs/decomposed-runs.md +42 -0
  8. package/docs/forge-ui.md +42 -6
  9. package/docs/reviews.md +14 -0
  10. package/forge +15 -0
  11. package/package.json +1 -1
  12. package/references/dataset.md +18 -1
  13. package/references/decompose.md +19 -1
  14. package/references/ui.md +27 -5
  15. package/scripts/forge-config.py +347 -0
  16. package/scripts/forge-control.py +645 -0
  17. package/scripts/forge-issues.py +806 -0
  18. package/scripts/forge-lib-task.sh +234 -35
  19. package/scripts/forge-parallel-options.sh +3 -1
  20. package/scripts/forge-parallel.sh +3 -2
  21. package/scripts/forge-route.py +184 -27
  22. package/scripts/forge-runs-rm.py +840 -0
  23. package/scripts/forge-runs.py +19 -1
  24. package/scripts/forge-schedule.py +227 -11
  25. package/scripts/forge-ui.py +85 -19
  26. package/scripts/forge_dataset/__init__.py +77 -6
  27. package/scripts/forge_dataset/capture.py +5 -2
  28. package/scripts/forge_dataset/harvest.py +22 -0
  29. package/scripts/forge_dataset/importer.py +419 -17
  30. package/scripts/forge_registry.py +168 -16
  31. package/ui/README.md +37 -4
  32. package/ui/docs/api.md +184 -6
  33. package/ui/forge_ui/__main__.py +10 -2
  34. package/ui/forge_ui/control.py +575 -0
  35. package/ui/forge_ui/export.py +17 -3
  36. package/ui/forge_ui/reader.py +26 -1
  37. package/ui/forge_ui/server.py +125 -4
  38. package/ui/forge_ui/static/app.js +80 -8
  39. package/ui/forge_ui/static/controls.js +619 -0
  40. package/ui/forge_ui/static/findingsview.js +412 -0
  41. package/ui/forge_ui/static/index.html +7 -1
  42. package/ui/forge_ui/static/panel.js +24 -4
  43. package/ui/forge_ui/static/rundelete.js +388 -0
  44. package/ui/forge_ui/static/settings.js +399 -0
  45. package/ui/forge_ui/static/style.css +177 -2
  46. package/ui/forge_ui/static/uikit.js +161 -0
@@ -0,0 +1,347 @@
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
+ base = os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local' / 'share')
205
+ home = Path(base).expanduser() / 'forge'
206
+ enabled = os.environ.get('FORGE_DATASET', 'on').strip().lower() not in ('off', '0', 'no', 'false')
207
+ return dict(path=str(home / 'dataset'), enabled=enabled)
208
+
209
+
210
+ def env_block():
211
+ out = {}
212
+ for name in sorted(os.environ):
213
+ if name.startswith('FORGE_'):
214
+ out[name] = '<set>' if any(w in name for w in SECRET_WORDS) else os.environ[name]
215
+ return out
216
+
217
+
218
+ def cmd_show(args):
219
+ root = repo_root(args.repo, required=False)
220
+ data = dict(registry=registry_block(), jev=jev_block())
221
+ if root:
222
+ data['repo'] = repo_block(root)
223
+ data['dataset'] = dataset_block()
224
+ data['env'] = env_block()
225
+ if args.json:
226
+ print(json.dumps(data, indent=2))
227
+ return 0
228
+ reg = data['registry']
229
+ print('registry: %s%s (%d rows, read-only)' % (reg['path'], ' [FORGE_REGISTRY]' if reg['overridden'] else '',
230
+ len(reg['rows'])))
231
+ for row in reg['rows']:
232
+ print(' %-10s %-10s %s %s %s' % (row['alias'], row['harness'], row['model'], row['effort'], row['timeout']))
233
+ jev = data['jev']
234
+ print('jev: %s (%s, %s)' % ('enabled' if jev['enabled'] else 'disabled', jev['path'],
235
+ 'present' if jev['exists'] else 'absent'))
236
+ if root:
237
+ print('repo: %s' % root)
238
+ for name, entry in data['repo']['files'].items():
239
+ shown = entry['content'].strip() if entry['content'] is not None else \
240
+ ('(%s)' % entry['note'] if entry.get('note') else '(none)')
241
+ print(' .forge/%s: %s' % (name, shown))
242
+ print('dataset: %s (%s)' % (data['dataset']['path'], 'on' if data['dataset']['enabled'] else 'off'))
243
+ for name, value in data['env'].items():
244
+ print('%s=%s' % (name, value))
245
+ return 0
246
+
247
+
248
+ # ----------------------------------------------------------------------------------- set / unset
249
+
250
+ def check_key(key):
251
+ if key not in KEYS:
252
+ raise Invalid('unknown key %r; valid keys: %s' % (key, ', '.join(KEYS)))
253
+
254
+
255
+ def emit(args, key, previous, value):
256
+ if args.json:
257
+ print(json.dumps(dict(ok=True, key=key, previous=previous, value=value)))
258
+ else:
259
+ print('%s: %s' % (key, 'unset' if value is None else 'set'))
260
+
261
+
262
+ def cmd_set(args):
263
+ check_key(args.key)
264
+ if args.key == 'jev.enabled':
265
+ return set_jev(args)
266
+ root = repo_root(args.repo, required=True)
267
+ if root is None:
268
+ raise Invalid('--repo DIR is required for %s' % args.key)
269
+ value = sys.stdin.read() if args.value == '-' else args.value
270
+ name = args.key[len('repo.'):]
271
+ if not value.strip():
272
+ raise Invalid('empty value; use `forge config unset %s` to remove it' % args.key)
273
+ if name == 'verify-subset' and '{files}' not in value:
274
+ raise Invalid('repo.verify-subset must contain {files}')
275
+ d, target = safe_target(root, name)
276
+ previous = read_small(target)[0] if d else None
277
+ if not value.endswith('\n'):
278
+ value += '\n'
279
+ atomic_write(os.path.join(root, '.forge'), target, value)
280
+ emit(args, args.key, previous, value)
281
+ return 0
282
+
283
+
284
+ def cmd_unset(args):
285
+ check_key(args.key)
286
+ if args.key == 'jev.enabled':
287
+ raise Invalid('jev.enabled cannot be unset; use `set jev.enabled true|false`')
288
+ root = repo_root(args.repo, required=True)
289
+ if root is None:
290
+ raise Invalid('--repo DIR is required for %s' % args.key)
291
+ d, target = safe_target(root, args.key[len('repo.'):])
292
+ previous = read_small(target)[0] if d else None
293
+ if d and os.path.lexists(target):
294
+ dfd = open_forge_dir(d)
295
+ try:
296
+ os.unlink(os.path.basename(target), dir_fd=dfd)
297
+ finally:
298
+ os.close(dfd)
299
+ emit(args, args.key, previous, None)
300
+ return 0
301
+
302
+
303
+ def set_jev(args):
304
+ if args.value not in ('true', 'false'):
305
+ raise Invalid('jev.enabled takes true or false')
306
+ previous = jev_block()['enabled']
307
+ verb = 'enable' if args.value == 'true' else 'disable'
308
+ run = subprocess.run([sys.executable, os.path.join(SKILL, 'scripts', 'forge-jev.py'), verb],
309
+ stdout=subprocess.DEVNULL, stderr=None)
310
+ if run.returncode != 0:
311
+ return run.returncode
312
+ emit(args, args.key, previous, args.value == 'true')
313
+ return 0
314
+
315
+
316
+ def parser():
317
+ p = argparse.ArgumentParser(prog='forge config', description=__doc__.split('\n\n')[0])
318
+ sub = p.add_subparsers(dest='cmd', required=True)
319
+ s = sub.add_parser('show')
320
+ s.add_argument('--repo')
321
+ s.add_argument('--json', action='store_true')
322
+ s.set_defaults(fn=cmd_show)
323
+ s = sub.add_parser('set')
324
+ s.add_argument('key')
325
+ s.add_argument('value')
326
+ s.add_argument('--repo')
327
+ s.add_argument('--json', action='store_true')
328
+ s.set_defaults(fn=cmd_set)
329
+ s = sub.add_parser('unset')
330
+ s.add_argument('key')
331
+ s.add_argument('--repo')
332
+ s.add_argument('--json', action='store_true')
333
+ s.set_defaults(fn=cmd_unset)
334
+ return p
335
+
336
+
337
+ def main(argv=None):
338
+ args = parser().parse_args(argv)
339
+ try:
340
+ return args.fn(args)
341
+ except Invalid as err:
342
+ print('forge config: %s' % err, file=sys.stderr)
343
+ return 2
344
+
345
+
346
+ if __name__ == '__main__':
347
+ sys.exit(main())