@giamme/forge 0.8.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 (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +22 -3
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +84 -0
  8. package/docs/commands.md +365 -0
  9. package/docs/dataset.md +29 -0
  10. package/docs/decomposed-runs.md +310 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +128 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +245 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +20 -0
  21. package/package.json +3 -2
  22. package/references/dataset.md +18 -1
  23. package/references/decompose.md +20 -2
  24. package/references/fractal.md +1 -1
  25. package/references/harnesses.md +18 -6
  26. package/references/solo.md +1 -1
  27. package/references/ui.md +27 -5
  28. package/scripts/forge-config.py +347 -0
  29. package/scripts/forge-control.py +645 -0
  30. package/scripts/forge-dispatch.sh +7 -1
  31. package/scripts/forge-issues.py +806 -0
  32. package/scripts/forge-lib-task.sh +234 -35
  33. package/scripts/forge-parallel-options.sh +3 -1
  34. package/scripts/forge-parallel.sh +3 -2
  35. package/scripts/forge-route.py +184 -27
  36. package/scripts/forge-runs-rm.py +840 -0
  37. package/scripts/forge-runs.py +139 -0
  38. package/scripts/forge-runtime.py +59 -6
  39. package/scripts/forge-schedule.py +227 -11
  40. package/scripts/forge-ui.py +85 -19
  41. package/scripts/forge-version.py +54 -12
  42. package/scripts/forge_dataset/__init__.py +77 -6
  43. package/scripts/forge_dataset/capture.py +5 -2
  44. package/scripts/forge_dataset/harvest.py +23 -1
  45. package/scripts/forge_dataset/importer.py +420 -18
  46. package/scripts/forge_fractal/__init__.py +2 -1
  47. package/scripts/forge_registry.py +168 -16
  48. package/ui/README.md +116 -23
  49. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  50. package/ui/docs/api.md +283 -23
  51. package/ui/forge_ui/__main__.py +10 -2
  52. package/ui/forge_ui/control.py +575 -0
  53. package/ui/forge_ui/export.py +33 -9
  54. package/ui/forge_ui/fractal.py +1 -2
  55. package/ui/forge_ui/reader.py +121 -8
  56. package/ui/forge_ui/server.py +135 -5
  57. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  58. package/ui/forge_ui/static/app.js +358 -131
  59. package/ui/forge_ui/static/cloud3d.js +3 -2
  60. package/ui/forge_ui/static/cloudui.js +8 -5
  61. package/ui/forge_ui/static/controls.js +619 -0
  62. package/ui/forge_ui/static/convo.js +295 -0
  63. package/ui/forge_ui/static/datatable.js +232 -0
  64. package/ui/forge_ui/static/diffview.js +29 -10
  65. package/ui/forge_ui/static/findings3d.js +134 -0
  66. package/ui/forge_ui/static/findingsview.js +412 -0
  67. package/ui/forge_ui/static/graph.js +3 -3
  68. package/ui/forge_ui/static/icons.js +94 -0
  69. package/ui/forge_ui/static/index.html +31 -13
  70. package/ui/forge_ui/static/iso.js +70 -19
  71. package/ui/forge_ui/static/leaderboard.js +44 -48
  72. package/ui/forge_ui/static/panel.js +92 -36
  73. package/ui/forge_ui/static/rundelete.js +388 -0
  74. package/ui/forge_ui/static/runmodel.js +17 -1
  75. package/ui/forge_ui/static/scene3d.js +518 -43
  76. package/ui/forge_ui/static/search.js +2 -1
  77. package/ui/forge_ui/static/settings.js +399 -0
  78. package/ui/forge_ui/static/shell.js +137 -0
  79. package/ui/forge_ui/static/stagectl.js +150 -0
  80. package/ui/forge_ui/static/style.css +1099 -469
  81. package/ui/forge_ui/static/theme.js +25 -0
  82. package/ui/forge_ui/static/uikit.js +161 -0
  83. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,840 @@
1
+ #!/usr/bin/env python3
2
+ """forge runs rm <run-dir> [--dry-run] [--yes] [--no-archive] [--worktrees] [--branches] [--json]
3
+
4
+ Delete a run directory without losing what it taught the dataset. In order, with the run's own
5
+ lock held from the first step to the last (`schedule.lock` of a plan, `pipeline.lock` of a solo
6
+ run, the locks the runners take), so a run started meanwhile fails to start instead of racing
7
+ the delete:
8
+
9
+ preserve a strict dataset import of the run: every event must be stored
10
+ archive a self-describing .tar.gz under <data home>/archive/, never replacing one (--no-archive skips it)
11
+ intent the dataset event run_delete_started
12
+ worktrees --worktrees: remove the run's clean worktrees, then `git worktree prune`
13
+ branches --branches: `git branch -D` the run's branches not checked out anywhere
14
+ remove shutil.rmtree of the run dir
15
+ record the dataset event run_deleted, from the identity read before the removal
16
+ unregister drop the path from every run registry file that lists it
17
+
18
+ A failure in preserve, archive or intent stops everything and deletes nothing. A failure later is
19
+ reported per step; the run dir is kept when worktrees or branches could not be dealt with, and
20
+ after a failed removal the files that identify the run are the last to go, so the same command
21
+ finishes the job. `forge runs rm` on a path that no longer exists only unregisters it.
22
+
23
+ What the run owns on the repo's side is decided here, not read from the run dir: a worktree is the
24
+ run's only under <repo>/../.forge-worktrees/<run id>/ (as forge-parallel.sh derives it, not from
25
+ the `wt_root` file) on a branch forge/<run id>/<task> for a task in tasks.tsv or
26
+ forge/<run id>-integration. Any other worktree in that place is `foreign`, and never touched. A
27
+ worktree with uncommitted or untracked changes is `dirty`: never removed, and --worktrees then
28
+ keeps the run dir too.
29
+
30
+ --json prints one object on stdout, on failure too:
31
+ {run_dir, run_id, dry_run, ok, steps: [{step, ok, detail}], archive, bytes_freed,
32
+ branches: [{name, sha, status: merged|UNMERGED, checked_out, stays_checked_out, deleted, note}],
33
+ worktrees: [{path, branch, kind: run|foreign, dirty, removed, note}],
34
+ size_bytes, archive_bytes, archive_estimate_bytes}
35
+ `checked_out` is the worktree that has the branch checked out now, `stays_checked_out` the one that still
36
+ will after the worktrees step as requested (with --worktrees the run's clean, unlocked worktrees go, and
37
+ so does their hold): --branches deletes exactly the branches where it is null.
38
+ `ok` means the requested deletion is complete (a dry run says what a real one would do).
39
+ Exit 0 ok, 2 refused input, 3 busy or active, 4 failed during deletion (or the run dir kept).
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import errno
45
+ import fcntl
46
+ import hashlib
47
+ import itertools
48
+ import json
49
+ import os
50
+ import re
51
+ import shlex
52
+ import shutil
53
+ import stat
54
+ import subprocess
55
+ import sys
56
+ import time
57
+ from pathlib import Path
58
+
59
+ SKILL = Path(__file__).resolve().parent.parent
60
+ sys.path.insert(0, str(SKILL / 'scripts'))
61
+
62
+ import forge_registry # noqa: E402
63
+ from forge_dataset import DatasetError, data_home, dataset_dir, enabled, harvest, importer, record_strict # noqa: E402
64
+
65
+ OK, REFUSED, BUSY, FAILED = 0, 2, 3, 4
66
+ # fullmatch: with `$` alone, a trailing newline would pass
67
+ RUN_ID = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$')
68
+ TASK_ID = RUN_ID
69
+ GIT_TIMEOUT_S = 120
70
+ READ_MAX = 8 << 20
71
+ # Test hook: a file path. Once the lock is held the command creates that file and waits (at
72
+ # most PAUSE_MAX_S) for it to be removed, so a test can try the lock from outside.
73
+ PAUSE_ENV = 'FORGE_RUNS_RM_PAUSE_AFTER_LOCK'
74
+ PAUSE_MAX_S = 30
75
+ # What identifies a run dir (and, dataset.uid, names it in the dataset). Removed after everything
76
+ # else, so a removal that fails half way leaves a dir this command still accepts, and a rerun
77
+ # imports it as the run it was.
78
+ IDENTITY = ('tasks.tsv', 'prompt.md', 'repo', 'run_id', 'dataset.uid')
79
+ GIT_ENV_DROP = ('GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY',
80
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_PREFIX')
81
+
82
+
83
+ class Refused(Exception):
84
+ """Input this command will not act on (exit 2)."""
85
+
86
+
87
+ class Busy(Exception):
88
+ """The run is, or may be, in use (exit 3)."""
89
+
90
+
91
+ class Failed(Exception):
92
+ """Something that had to work did not (exit 4)."""
93
+
94
+
95
+ class UsageError(Exception):
96
+ pass
97
+
98
+
99
+ class Parser(argparse.ArgumentParser):
100
+ def error(self, message):
101
+ raise UsageError(message)
102
+
103
+
104
+ # ------------------------------------------------------------------------------ report
105
+
106
+ class Report:
107
+ """What happened, kept as data so --json can always print it, and printed live otherwise."""
108
+
109
+ def __init__(self, args):
110
+ self.run_dir = args.run_dir
111
+ self.run_id = None
112
+ self.dry_run = args.dry_run
113
+ self.json = args.json
114
+ self.ok = True
115
+ self.steps = []
116
+ self.archive = None
117
+ self.bytes_freed = 0
118
+ self.branches = []
119
+ self.worktrees = []
120
+ self.extra = {}
121
+
122
+ def step(self, name, ok, detail):
123
+ self.steps.append({'step': name, 'ok': bool(ok), 'detail': detail})
124
+ if not ok:
125
+ self.ok = False
126
+ if not self.json:
127
+ print(' %-4s %-10s %s' % ('ok' if ok else 'FAIL', name, detail), file=sys.stdout if ok else sys.stderr)
128
+
129
+ def warn(self, text):
130
+ print('forge runs rm: WARNING: ' + text, file=sys.stderr)
131
+
132
+ def data(self):
133
+ return dict(run_dir=self.run_dir, run_id=self.run_id, dry_run=self.dry_run, ok=self.ok, steps=self.steps,
134
+ archive=self.archive, bytes_freed=self.bytes_freed, branches=self.branches,
135
+ worktrees=self.worktrees, **self.extra)
136
+
137
+ def finish(self, code):
138
+ if self.json:
139
+ json.dump(self.data(), sys.stdout, indent=2)
140
+ sys.stdout.write('\n')
141
+ else:
142
+ for wt in self.worktrees:
143
+ state = wt['kind'] + (', dirty' if wt['dirty'] else '') + (', removed' if wt['removed'] else '')
144
+ print(' worktree %s %s (%s)%s' % (wt['path'], wt['branch'] or 'detached', state,
145
+ ' ' + wt['note'] if wt['note'] else ''))
146
+ for branch in self.branches:
147
+ print(' branch %s %s%s%s' % (branch['name'], branch['status'], ', deleted' if branch['deleted'] else '',
148
+ ' ' + branch['note'] if branch['note'] else ''))
149
+ if not self.ok:
150
+ print('forge runs rm: not completed (exit %d), see the steps above' % code, file=sys.stderr)
151
+ elif self.dry_run:
152
+ print('forge runs rm: dry run, nothing was changed')
153
+ else:
154
+ print('forge runs rm: done')
155
+ return code
156
+
157
+
158
+ def interactive():
159
+ try:
160
+ return bool(sys.stdin) and sys.stdin.isatty()
161
+ except (ValueError, OSError):
162
+ return False
163
+
164
+
165
+ def size_text(count):
166
+ value = float(count)
167
+ for unit in ('B', 'KB', 'MB', 'GB'):
168
+ if value < 1024 or unit == 'GB':
169
+ return ('%d %s' % (value, unit)) if unit == 'B' else ('%.1f %s' % (value, unit))
170
+ value /= 1024
171
+
172
+
173
+ # ------------------------------------------------------------------------------ the run dir
174
+
175
+ def contains(parent, child):
176
+ """Whether `child` is `parent` or below it (both real, absolute paths)."""
177
+ return child == parent or child.startswith(parent.rstrip('/') + '/')
178
+
179
+
180
+ class Target:
181
+ def __init__(self, path, info):
182
+ self.path = path
183
+ self.info = info
184
+
185
+
186
+ def locate(arg):
187
+ """The real path the argument names. The argument itself must not be a symlink."""
188
+ if not arg:
189
+ raise Refused('no run dir given')
190
+ raw = arg if os.path.isabs(arg) else os.path.join(os.getcwd(), arg)
191
+ raw = raw.rstrip('/') or '/'
192
+ try:
193
+ info = os.lstat(raw)
194
+ except (FileNotFoundError, NotADirectoryError):
195
+ return Target(os.path.join(os.path.realpath(os.path.dirname(raw)), os.path.basename(raw)), None)
196
+ except OSError as error:
197
+ raise Refused('cannot look at %s: %s' % (arg, error.strerror or error))
198
+ if stat.S_ISLNK(info.st_mode):
199
+ raise Refused('%s is a symbolic link: name the run dir itself' % arg)
200
+ if not stat.S_ISDIR(info.st_mode):
201
+ raise Refused('%s is not a directory' % arg)
202
+ return Target(os.path.realpath(raw), info)
203
+
204
+
205
+ def read_text(path, cap=READ_MAX):
206
+ """A run file's text: None if absent, Refused if it is not a plain readable file. Never follows a link."""
207
+ try:
208
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
209
+ except FileNotFoundError:
210
+ return None
211
+ except OSError as error:
212
+ raise Refused('cannot read %s: %s' % (path, error.strerror or error))
213
+ with os.fdopen(fd, 'rb') as handle:
214
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
215
+ raise Refused('%s is not a regular file' % path)
216
+ return handle.read(cap).decode('utf-8', 'replace')
217
+
218
+
219
+ def first_line(path):
220
+ text = read_text(path, 64 * 1024)
221
+ line = (text or '').split('\n', 1)[0].strip()
222
+ return line or None
223
+
224
+
225
+ def homes():
226
+ found = {os.path.realpath(os.path.expanduser('~'))}
227
+ try:
228
+ import pwd
229
+ found.add(os.path.realpath(pwd.getpwuid(os.getuid()).pw_dir))
230
+ except (ImportError, KeyError, OSError):
231
+ pass
232
+ return found
233
+
234
+
235
+ class Run:
236
+ """A run dir that passed the identity and safety rules."""
237
+
238
+ def __init__(self, target):
239
+ self.path = target.path
240
+ self.dev, self.ino = target.info.st_dev, target.info.st_ino
241
+ self.kind = self.run_id = self.repo = self.repo_real = self.lock_path = self.mode = None
242
+ self.tasks = []
243
+ self.ignored_tasks = []
244
+ # filled in under the lock
245
+ self.size = self.estimate = self.left_out = 0
246
+ self.uid = None
247
+ self.git_error = None
248
+ self.worktrees = []
249
+ self.branches = []
250
+ self.removable = set() # paths of the worktrees --worktrees removes (or prunes, when missing on disk)
251
+ self.wt_root = None
252
+
253
+
254
+ def identify(target):
255
+ path = target.path
256
+ if path == '/' or any(contains(path, home) for home in homes()):
257
+ raise Refused('refusing to delete %s: it is, or contains, the home directory' % path)
258
+ if os.path.lexists(os.path.join(path, '.git')):
259
+ raise Refused('refusing to delete %s: it has a .git at its top (a repository, not a run)' % path)
260
+ run = Run(target)
261
+ if os.path.isfile(os.path.join(path, 'tasks.tsv')):
262
+ run.kind, run.mode, run.lock_path = 'plan', 'parallel', os.path.join(path, 'schedule.lock')
263
+ elif os.path.isfile(os.path.join(path, 'prompt.md')) and os.path.isfile(os.path.join(path, 'repo')):
264
+ run.kind, run.mode, run.lock_path = 'solo', 'solo', os.path.join(path, 'pipeline.lock')
265
+ else:
266
+ raise Refused('%s is not a forge run dir (no tasks.tsv, and no prompt.md with a repo file)' % path)
267
+ run.run_id = first_line(os.path.join(path, 'run_id')) or os.path.basename(path)
268
+ if not RUN_ID.fullmatch(run.run_id):
269
+ raise Refused('run id %r is not safe as a file name and a ref component (%s)' % (run.run_id, RUN_ID.pattern))
270
+ run.repo = first_line(os.path.join(path, 'repo'))
271
+ if not run.repo or not os.path.isabs(run.repo) or '\x00' in run.repo:
272
+ raise Refused('%s has no usable repo file: cannot tell which repo the run belongs to' % path)
273
+ run.repo_real = os.path.realpath(run.repo)
274
+ if contains(path, run.repo_real) or contains(path, os.path.abspath(run.repo)):
275
+ raise Refused('refusing to delete %s: it is, or contains, the run\'s repo %s' % (path, run.repo))
276
+ if run.kind == 'plan':
277
+ seen = set()
278
+ for line in (read_text(os.path.join(path, 'tasks.tsv')) or '').splitlines():
279
+ if not line.strip() or line.startswith('#'):
280
+ continue
281
+ task = line.split('\t', 1)[0]
282
+ if not TASK_ID.fullmatch(task):
283
+ run.ignored_tasks.append(task)
284
+ elif task not in seen:
285
+ seen.add(task)
286
+ run.tasks.append(task)
287
+ # forge-parallel.sh: wt_root="$(cd "$REPO/.." && pwd)/.forge-worktrees/$run_id", a logical cd
288
+ run.wt_root = os.path.realpath(os.path.join(os.path.normpath(os.path.join(run.repo, '..')), '.forge-worktrees', run.run_id))
289
+ if contains(path, run.wt_root) or contains(run.wt_root, path):
290
+ raise Refused('refusing to delete %s: it overlaps the run\'s worktree root %s' % (path, run.wt_root))
291
+ return run
292
+
293
+
294
+ def refuse_destinations(run, args):
295
+ """The archive and the dataset must be written somewhere that outlives the run dir."""
296
+ spots = [] if args.no_archive else [('archive', data_home() / 'archive')]
297
+ if enabled():
298
+ spots.append(('dataset', dataset_dir()))
299
+ for name, spot in spots:
300
+ if contains(run.path, os.path.realpath(spot)):
301
+ raise Refused('the %s destination %s is inside the run dir being deleted' % (name, spot))
302
+
303
+
304
+ def check_unchanged(run, why):
305
+ try:
306
+ now = os.lstat(run.path)
307
+ except OSError:
308
+ raise Busy('the run dir disappeared %s' % why)
309
+ if (now.st_dev, now.st_ino) != (run.dev, run.ino) or not stat.S_ISDIR(now.st_mode):
310
+ raise Busy('the run dir was replaced %s' % why)
311
+
312
+
313
+ def scheduler_pid(run):
314
+ """The pid of a live scheduler, from scheduler.live, else None."""
315
+ if run.kind != 'plan':
316
+ return None
317
+ try:
318
+ pid = json.loads(read_text(os.path.join(run.path, 'scheduler.live'), 64 * 1024) or '')['pid']
319
+ except (ValueError, KeyError, TypeError, Refused):
320
+ return None
321
+ if type(pid) is not int or pid <= 0:
322
+ return None
323
+ try:
324
+ os.kill(pid, 0)
325
+ except ProcessLookupError:
326
+ return None
327
+ except PermissionError:
328
+ return pid
329
+ except (OSError, OverflowError):
330
+ return None
331
+ return pid
332
+
333
+
334
+ def take_lock(run):
335
+ """The run's operation lock, non-blocking, as the runners take it. Returns the open fd."""
336
+ try:
337
+ fd = os.open(run.lock_path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644)
338
+ except OSError as error:
339
+ raise Failed('cannot open %s: %s' % (run.lock_path, error.strerror or error))
340
+ try:
341
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
342
+ raise Refused('%s is not a regular file' % run.lock_path)
343
+ try:
344
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
345
+ except OSError as error:
346
+ if error.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
347
+ raise Busy('%s is held by a running forge command' % os.path.basename(run.lock_path)) from error
348
+ raise Failed('cannot lock %s: %s' % (run.lock_path, error.strerror or error)) from error
349
+ check_unchanged(run, 'while it was being locked')
350
+ held, linked = os.fstat(fd), os.lstat(run.lock_path)
351
+ if (held.st_dev, held.st_ino) != (linked.st_dev, linked.st_ino):
352
+ raise Busy('%s was replaced while it was being locked' % os.path.basename(run.lock_path))
353
+ except BaseException:
354
+ os.close(fd)
355
+ raise
356
+ return fd
357
+
358
+
359
+ def pause_for_test():
360
+ marker = os.environ.get(PAUSE_ENV)
361
+ if not marker:
362
+ return
363
+ Path(marker).write_text(str(os.getpid()))
364
+ deadline = time.monotonic() + PAUSE_MAX_S
365
+ while os.path.exists(marker) and time.monotonic() < deadline:
366
+ time.sleep(0.02)
367
+
368
+
369
+ def scan(run):
370
+ """Bytes in the run dir, and what archive_run would put in an archive of it."""
371
+ total = estimate = left_out = 0
372
+ stack = [(run.path, True)]
373
+ while stack:
374
+ directory, included = stack.pop()
375
+ try:
376
+ entries = list(os.scandir(directory))
377
+ except OSError:
378
+ continue
379
+ for entry in entries:
380
+ try:
381
+ info = entry.stat(follow_symlinks=False)
382
+ except OSError:
383
+ continue
384
+ if stat.S_ISDIR(info.st_mode):
385
+ stack.append((entry.path, included and entry.name not in importer.SKIP_DIRS
386
+ and not entry.name.startswith('review-')))
387
+ continue
388
+ total += info.st_size
389
+ if included:
390
+ if stat.S_ISREG(info.st_mode) and info.st_size <= importer.ARCHIVE_MAX_FILE:
391
+ estimate += info.st_size
392
+ else:
393
+ left_out += 1
394
+ run.size, run.estimate, run.left_out = total, estimate, left_out
395
+
396
+
397
+ # ------------------------------------------------------------------------------ git
398
+
399
+ def git(repo, *args):
400
+ env = {k: v for k, v in os.environ.items() if k not in GIT_ENV_DROP}
401
+ env.update(GIT_TERMINAL_PROMPT='0', LC_ALL='C')
402
+ try:
403
+ done = subprocess.run(['git', '--no-optional-locks', '-C', repo, *args], capture_output=True, text=True,
404
+ errors='replace', stdin=subprocess.DEVNULL, timeout=GIT_TIMEOUT_S, env=env)
405
+ except (OSError, subprocess.SubprocessError) as error:
406
+ return 255, '', str(error)
407
+ return done.returncode, done.stdout, done.stderr.strip()
408
+
409
+
410
+ def list_worktrees(repo):
411
+ """`git worktree list --porcelain` as dicts; raises Failed if git cannot say."""
412
+ code, out, err = git(repo, 'worktree', 'list', '--porcelain')
413
+ if code != 0:
414
+ raise Failed('git worktree list failed in %s: %s' % (repo, err or 'exit %d' % code))
415
+ found, current = [], None
416
+ for line in out.splitlines():
417
+ if not line.strip():
418
+ current = None
419
+ continue
420
+ key, _, value = line.partition(' ')
421
+ if key == 'worktree':
422
+ current = {'path': value, 'branch': None, 'head': None}
423
+ found.append(current)
424
+ elif current is not None:
425
+ if key == 'branch':
426
+ current['branch'] = value
427
+ elif key == 'HEAD':
428
+ current['head'] = value
429
+ else:
430
+ current[key] = value or True
431
+ return found
432
+
433
+
434
+ def inspect_git(run):
435
+ """Classify the repo's worktrees and the run's branches. Sets run.git_error if git cannot tell."""
436
+ try:
437
+ listed = list_worktrees(run.repo)
438
+ except Failed as error:
439
+ run.git_error = str(error)
440
+ return
441
+ branch_refs = {'refs/heads/forge/%s/%s' % (run.run_id, task) for task in run.tasks}
442
+ branch_refs.add('refs/heads/forge/%s-integration' % run.run_id)
443
+ for wt in listed:
444
+ real = os.path.realpath(wt['path'])
445
+ in_root = real != run.wt_root and contains(run.wt_root, real)
446
+ ours = wt['branch'] in branch_refs
447
+ if not in_root and not ours:
448
+ continue # the main worktree, or another run's: not this command's business
449
+ entry = dict(path=wt['path'], branch=(wt['branch'] or '').replace('refs/heads/', '', 1) or None,
450
+ kind='run' if in_root and ours else 'foreign', dirty=None, removed=False, note='')
451
+ if entry['kind'] == 'run':
452
+ if not os.path.isdir(wt['path']):
453
+ entry.update(dirty=False, note='missing on disk; `git worktree prune` forgets it')
454
+ else:
455
+ code, out, err = git(wt['path'], 'status', '--porcelain', '--untracked-files=normal')
456
+ entry['dirty'] = code != 0 or bool(out.strip())
457
+ if code != 0:
458
+ entry['note'] = 'git status failed, treated as dirty: ' + (err or 'exit %d' % code)
459
+ if wt.get('locked'):
460
+ entry['note'] = (entry['note'] + ' ' if entry['note'] else '') + 'locked'
461
+ elif not entry['dirty']:
462
+ run.removable.add(wt['path']) # a locked one is neither removed nor pruned without --force
463
+ else:
464
+ entry['note'] = 'not this run\'s: ' + ('its branch is not one of the run\'s' if in_root else 'outside the run\'s worktree root')
465
+ run.worktrees.append(entry)
466
+ code, out, err = git(run.repo, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/forge/')
467
+ if code != 0:
468
+ run.git_error = 'git for-each-ref failed in %s: %s' % (run.repo, err or 'exit %d' % code)
469
+ return
470
+ checked_out = {wt['branch']: wt['path'] for wt in listed if wt['branch']}
471
+ for line in sorted(out.splitlines()):
472
+ ref, _, sha = line.rpartition(' ')
473
+ if ref not in branch_refs:
474
+ continue
475
+ merged = git(run.repo, 'merge-base', '--is-ancestor', sha, 'HEAD')[0] == 0
476
+ run.branches.append(dict(name=ref.replace('refs/heads/', '', 1), sha=sha, merged=merged,
477
+ status='merged' if merged else 'UNMERGED', checked_out=checked_out.get(ref),
478
+ stays_checked_out=checked_out.get(ref), deleted=False, note=''))
479
+
480
+
481
+ # ------------------------------------------------------------------------------ the steps
482
+
483
+ def archive_target(run):
484
+ """(the first archive name free now, the names to fall back on): <run id>-<time>[-<n>].tar.gz.
485
+
486
+ The first is what the plan shows. Another delete of the same run id can take it before the
487
+ archive is written, so archive_run claims a name exclusively and moves on to the fallbacks.
488
+ """
489
+ base = data_home() / 'archive' / ('%s-%s' % (run.run_id, time.strftime('%Y%m%d-%H%M%S')))
490
+ names = itertools.chain([str(base) + '.tar.gz'], ('%s-%d.tar.gz' % (base, count) for count in itertools.count(1)))
491
+ for name in names:
492
+ if not os.path.lexists(name):
493
+ return name, names
494
+
495
+
496
+ def plan_steps(run, args, dest, keep):
497
+ """[(step, active, what it does, or why it is skipped)] in execution order."""
498
+ on = enabled()
499
+ dirty = [wt for wt in run.worktrees if wt['kind'] == 'run' and wt['dirty']]
500
+ clean = [wt for wt in run.worktrees if wt['kind'] == 'run' and not wt['dirty']]
501
+ standing = sum(1 for wt in run.worktrees if wt['kind'] == 'run')
502
+ names = [b['name'] for b in run.branches]
503
+ archive = 'skipped (--no-archive)'
504
+ if not args.no_archive:
505
+ archive = 'write %s (about %s before compression%s)' % (
506
+ dest, size_text(run.estimate), '; %d file(s) left out' % run.left_out if run.left_out else '')
507
+ worktrees = 'skipped (no --worktrees)' + ('; %d run worktree(s) stay in place' % standing if standing else '')
508
+ if args.worktrees:
509
+ worktrees = 'remove %d clean worktree(s)%s, then git worktree prune' % (
510
+ len(clean), ', keep %d dirty' % len(dirty) if dirty else '')
511
+ branches = 'skipped (no --branches)' + ('; %d run branch(es) stay' % len(names) if names else '')
512
+ if args.branches:
513
+ going = [b['name'] for b in run.branches if not b['stays_checked_out']]
514
+ held = ['%s (checked out in %s)' % (b['name'], b['stays_checked_out']) for b in run.branches if b['stays_checked_out']]
515
+ branches = 'delete %s%s' % (', '.join(going) or 'no branch' + ('' if names else ' (the run has none)'),
516
+ '; keep ' + ', '.join(held) if held else '')
517
+ return [
518
+ ('preserve', on, 'import the run into the dataset, strictly: every event must be stored' if on else
519
+ 'skipped: FORGE_DATASET=off, so this run\'s traces will NOT be kept in the dataset'),
520
+ ('archive', not args.no_archive, archive),
521
+ ('intent', on, 'record the dataset event run_delete_started' if on else 'skipped: FORGE_DATASET=off'),
522
+ ('worktrees', args.worktrees, worktrees),
523
+ ('branches', args.branches, branches),
524
+ ('remove', not keep, ('remove %s (%s)' % (run.path, size_text(run.size))) if not keep else 'kept: ' + keep),
525
+ ('record', on and not keep, 'record the dataset event run_deleted' if on and not keep else
526
+ 'skipped: ' + ('the run dir is kept' if keep else 'FORGE_DATASET=off')),
527
+ ('unregister', not keep, 'remove the path from every registry file that lists it' if not keep else
528
+ 'skipped: the run dir is kept'),
529
+ ]
530
+
531
+
532
+ def event(run, name, **values):
533
+ stamp = json.dumps([run.uid, name, run.path, time.time_ns()])
534
+ return dict(rid='event:%s:%s' % (name, hashlib.sha256(stamp.encode()).hexdigest()[:24]), kind='event',
535
+ ts=time.time(), run=run.uid, run_id=run.run_id, mode=run.mode, task=None,
536
+ parent=os.environ.get('FORGE_DATASET_PARENT') or None,
537
+ inv=os.environ.get('FORGE_DATASET_INVOCATION') or None,
538
+ data=dict(values, name=name, blobs={}))
539
+
540
+
541
+ def remove_entry(entry, errors):
542
+ try:
543
+ if entry.is_dir(follow_symlinks=False):
544
+ shutil.rmtree(entry.path) # never follows a symlink
545
+ else:
546
+ os.unlink(entry.path)
547
+ except OSError as error:
548
+ errors.append('%s: %s' % (entry.path, error.strerror or error))
549
+
550
+
551
+ def remove_run_dir(run):
552
+ """rmtree the run dir, keeping the lock file and the files that identify the run for last.
553
+
554
+ The lock file stays until the dir is about to go, so a runner starting meanwhile finds
555
+ the lock we hold instead of creating a fresh one. If anything fails the identity files
556
+ are still there and `forge runs rm` accepts the dir again.
557
+ """
558
+ with os.scandir(run.path) as listing:
559
+ entries = list(listing)
560
+ lock = os.path.basename(run.lock_path)
561
+ errors = []
562
+ for entry in entries:
563
+ if entry.name != lock and entry.name not in IDENTITY:
564
+ remove_entry(entry, errors)
565
+ if errors:
566
+ raise Failed('%d error(s), the run dir stays: %s' % (len(errors), '; '.join(errors[:3])))
567
+ for entry in sorted((e for e in entries if e.name == lock or e.name in IDENTITY), key=lambda e: e.name == lock):
568
+ remove_entry(entry, errors)
569
+ if errors:
570
+ raise Failed('; '.join(errors[:3]))
571
+ try:
572
+ os.rmdir(run.path)
573
+ except OSError as error:
574
+ raise Failed('cannot remove %s: %s' % (run.path, error.strerror or error))
575
+
576
+
577
+ def remove_worktrees(run):
578
+ """Step 4: `git worktree remove` (never --force) on the run's clean worktrees, then prune. Returns an error text, or None."""
579
+ problems = []
580
+ for entry in run.worktrees:
581
+ if entry['kind'] != 'run' or entry['dirty'] or entry['note'].startswith('missing'):
582
+ continue
583
+ code, _, err = git(run.repo, 'worktree', 'remove', entry['path'])
584
+ if code == 0:
585
+ entry['removed'] = True
586
+ else:
587
+ entry['note'] = (err or 'git worktree remove exit %d' % code).splitlines()[0]
588
+ problems.append('%s: %s' % (entry['path'], entry['note']))
589
+ code, _, err = git(run.repo, 'worktree', 'prune')
590
+ if code != 0:
591
+ problems.append('git worktree prune: ' + (err or 'exit %d' % code))
592
+ if os.path.isdir(run.wt_root) and not os.path.islink(run.wt_root):
593
+ try:
594
+ os.rmdir(run.wt_root) # only succeeds when it is empty: a foreign worktree keeps it
595
+ except OSError:
596
+ pass
597
+ return '; '.join(problems) or None
598
+
599
+
600
+ def delete_branches(run):
601
+ """Step 5: `git branch -D` the run's branches that no remaining worktree has checked out. Returns an error text, or None."""
602
+ remaining = {wt['branch']: wt['path'] for wt in list_worktrees(run.repo) if wt['branch']}
603
+ problems = []
604
+ for branch in run.branches:
605
+ where = remaining.get('refs/heads/' + branch['name'])
606
+ if where:
607
+ branch['note'] = 'kept: checked out in ' + where
608
+ continue
609
+ code, _, err = git(run.repo, 'branch', '-D', branch['name'])
610
+ if code == 0:
611
+ branch['deleted'] = True
612
+ branch['note'] = 'was %s; restore with: git branch %s %s' % (branch['sha'][:12], branch['name'], branch['sha'])
613
+ else:
614
+ branch['note'] = err or 'git branch -D exit %d' % code
615
+ problems.append('%s: %s' % (branch['name'], branch['note']))
616
+ return '; '.join(problems) or None
617
+
618
+
619
+ def execute(run, args, report, dest, fallbacks, keep):
620
+ """Steps 1 to 8 with the lock held. Returns the exit code."""
621
+ plan = {name: (active, text) for name, active, text in plan_steps(run, args, dest, keep)}
622
+
623
+ def skipped(name):
624
+ if plan[name][0]:
625
+ return False
626
+ report.step(name, True, plan[name][1])
627
+ return True
628
+
629
+ if not skipped('preserve'):
630
+ try:
631
+ result = importer.import_run(run.path, strict=True)
632
+ except (DatasetError, OSError) as error:
633
+ report.step('preserve', False, 'the dataset could not take the run, nothing was deleted: %s' % error)
634
+ return FAILED
635
+ unfinished = result.get('skipped_unfinished')
636
+ report.step('preserve', True, 'imported %d event(s), %d new%s' % (
637
+ result['events'], result['new'], ', %d unfinished execution(s) left to live capture' % unfinished if unfinished else ''))
638
+ if not skipped('archive'):
639
+ try:
640
+ made = importer.archive_run(run.path, dest, fallbacks)
641
+ except (DatasetError, OSError) as error:
642
+ report.step('archive', False, 'no archive, nothing was deleted: %s' % error)
643
+ return FAILED
644
+ report.archive = made['path']
645
+ report.extra['archive_bytes'] = made['bytes']
646
+ report.step('archive', True, 'wrote %s (%s%s)' % (made['path'], size_text(made['bytes']),
647
+ '; %d file(s) left out, listed inside' % len(made['excluded'])
648
+ if made['excluded'] else ''))
649
+ if not skipped('intent'):
650
+ try:
651
+ record_strict([event(run, 'run_delete_started', path=run.path, run_id=run.run_id, archive=report.archive)],
652
+ repo=run.repo, where='event:run_delete_started')
653
+ except DatasetError as error:
654
+ report.step('intent', False, 'could not record the intent, nothing was deleted: %s' % error)
655
+ return FAILED
656
+ report.step('intent', True, 'recorded run_delete_started')
657
+ blocked = False # a requested worktree or branch cleanup did not complete: keep the run dir
658
+ if not skipped('worktrees'):
659
+ try:
660
+ problem = remove_worktrees(run)
661
+ except Failed as error:
662
+ problem = str(error)
663
+ blocked = blocked or bool(problem)
664
+ report.step('worktrees', not problem, problem or '%d worktree(s) removed' % sum(wt['removed'] for wt in run.worktrees))
665
+ if not skipped('branches'):
666
+ try:
667
+ problem = delete_branches(run)
668
+ except Failed as error:
669
+ problem = str(error)
670
+ blocked = blocked or bool(problem)
671
+ report.step('branches', not problem, problem or '%d branch(es) deleted' % sum(b['deleted'] for b in run.branches))
672
+ if keep or blocked:
673
+ report.step('remove', False, 'kept: ' + (keep or 'a requested worktree or branch step failed, see above'))
674
+ report.step('record', True, 'skipped: the run dir is kept')
675
+ report.step('unregister', True, 'skipped: the run dir is kept')
676
+ return FAILED
677
+ try:
678
+ check_unchanged(run, 'during the operation, so it is not removed')
679
+ remove_run_dir(run)
680
+ except (Busy, Failed) as error:
681
+ report.step('remove', False, 'the run dir was not removed: %s' % error)
682
+ report.step('record', True, 'skipped: the run dir is kept')
683
+ report.step('unregister', True, 'skipped: the run dir is kept')
684
+ return FAILED
685
+ report.bytes_freed = run.size
686
+ report.step('remove', True, 'removed %s (%s)' % (run.path, size_text(run.size)))
687
+ code = OK
688
+ if not skipped('record'):
689
+ try:
690
+ record_strict([event(run, 'run_deleted', path=run.path, run_id=run.run_id, archive=report.archive, bytes_freed=run.size,
691
+ archive_bytes=report.extra.get('archive_bytes'))],
692
+ repo=run.repo, where='event:run_deleted')
693
+ report.step('record', True, 'recorded run_deleted')
694
+ except DatasetError as error:
695
+ report.step('record', False, 'removed, but run_deleted could not be recorded: %s' % error)
696
+ code = FAILED
697
+ try:
698
+ changed = forge_registry.unregister_run_dir(run.path)
699
+ report.step('unregister', True, 'removed from %d registry file(s)' % len(changed) if changed else 'it was not registered')
700
+ except OSError as error:
701
+ report.step('unregister', False, 'removed, registry needs repair: rerun `forge runs rm %s` (%s)' % (shlex.quote(run.path), error))
702
+ code = FAILED
703
+ return code
704
+
705
+
706
+ def confirm(run):
707
+ sys.stderr.write('delete %s (%s, run id %s)?\ntype the run id to delete: ' % (run.path, size_text(run.size), run.run_id))
708
+ sys.stderr.flush()
709
+ if sys.stdin.readline().strip() != run.run_id:
710
+ raise Refused('aborted: that is not the run id, nothing was changed')
711
+
712
+
713
+ # ------------------------------------------------------------------------------ command
714
+
715
+ def forget(target, args, report):
716
+ """The path is gone: all that is left to do is the registry."""
717
+ name = os.path.basename(target.path)
718
+ report.run_id = name if RUN_ID.fullmatch(name) else None
719
+ report.step('check', True, 'no such directory; only the registry entries are removed')
720
+ if args.dry_run:
721
+ report.step('unregister', True, 'remove the path from every registry file that lists it')
722
+ return OK
723
+ try:
724
+ changed = forge_registry.unregister_run_dir(target.path)
725
+ except OSError as error:
726
+ report.step('unregister', False, 'registry needs repair: rerun `forge runs rm %s` (%s)' % (shlex.quote(target.path), error))
727
+ return FAILED
728
+ report.step('unregister', True, 'removed from %d registry file(s)' % len(changed) if changed else 'it was not registered')
729
+ return OK
730
+
731
+
732
+ def run_command(args, report):
733
+ target = locate(args.run_dir)
734
+ report.run_dir = target.path
735
+ if target.info is None:
736
+ return forget(target, args, report)
737
+ run = identify(target)
738
+ report.run_id = run.run_id
739
+ refuse_destinations(run, args)
740
+ if not args.dry_run and not args.yes and not interactive():
741
+ raise Refused('nothing asked for the delete and this is not a terminal: pass --yes to delete %s' % run.path)
742
+ if not enabled():
743
+ report.warn('FORGE_DATASET=off: the traces of %s will NOT be kept in the dataset' % run.path)
744
+ fd = None
745
+ try:
746
+ if args.dry_run:
747
+ check_unchanged(run, 'while it was being inspected') # never probes the lock: a probe could fail a starting run
748
+ else:
749
+ fd = take_lock(run)
750
+ pause_for_test()
751
+ pid = scheduler_pid(run)
752
+ if pid:
753
+ raise Busy('scheduler.live names a live process (pid %d): the run is active; if that is not a forge '
754
+ 'scheduler, delete %s/scheduler.live' % (pid, run.path))
755
+ scan(run)
756
+ inspect_git(run)
757
+ report.worktrees, report.branches = run.worktrees, run.branches
758
+ if args.worktrees:
759
+ for branch in run.branches:
760
+ if branch['stays_checked_out'] in run.removable:
761
+ branch['stays_checked_out'] = None
762
+ if (args.worktrees or args.branches) and run.git_error:
763
+ raise Refused('cannot clean up worktrees or branches: ' + run.git_error)
764
+ try:
765
+ run.uid = harvest.run_uid(Path(run.path), create=False)
766
+ except Exception: # noqa: BLE001 -- an identity we cannot read only costs the event its run column
767
+ run.uid = None
768
+ dest, fallbacks = (None, ()) if args.no_archive else archive_target(run)
769
+ report.archive = dest if args.dry_run else None
770
+ report.extra.update(size_bytes=run.size, archive_bytes=None,
771
+ archive_estimate_bytes=None if args.no_archive else run.estimate)
772
+ dirty = [wt['path'] for wt in run.worktrees if wt['kind'] == 'run' and wt['dirty']]
773
+ keep = ('dirty worktree(s) hold uncommitted work: %s; commit or discard it there, then rerun' % ', '.join(dirty)
774
+ if args.worktrees and dirty else None)
775
+ report.step('check', True, '%s dir, run id %s, %d task(s), %s%s%s' % (
776
+ run.kind, run.run_id, len(run.tasks), size_text(run.size),
777
+ '; ignoring task id(s) unsafe as ref names: %s' % ', '.join(run.ignored_tasks) if run.ignored_tasks else '',
778
+ '; worktrees and branches unknown: ' + run.git_error if run.git_error else ''))
779
+ if args.dry_run:
780
+ report.step('lock', True, 'would hold %s from here to the end (not probed in a dry run)' % os.path.basename(run.lock_path))
781
+ for name, active, text in plan_steps(run, args, dest, keep):
782
+ report.step(name, name != 'remove' or active, ('would ' if active else '') + text)
783
+ return OK if report.ok else FAILED
784
+ report.step('lock', True, 'holding %s' % os.path.basename(run.lock_path))
785
+ if not args.yes:
786
+ confirm(run)
787
+ return execute(run, args, report, dest, fallbacks, keep)
788
+ finally:
789
+ if fd is not None:
790
+ os.close(fd)
791
+
792
+
793
+ def parser():
794
+ p = Parser(prog='forge runs rm', description='Delete a run dir, keeping its traces in the dataset. '
795
+ 'See the module docstring of scripts/forge-runs-rm.py for the steps and the safety rules.')
796
+ p.add_argument('run_dir', metavar='RUN_DIR', help='the plan dir or solo run dir to delete')
797
+ p.add_argument('--dry-run', action='store_true', help='change nothing; report what would happen')
798
+ p.add_argument('--yes', action='store_true', help='do not ask for the run id (needed when there is no terminal)')
799
+ p.add_argument('--no-archive', action='store_true', help='keep the traces in the dataset only, write no .tar.gz')
800
+ p.add_argument('--worktrees', action='store_true', help="also remove the run's clean worktrees")
801
+ p.add_argument('--branches', action='store_true', help="also delete the run's branches that no worktree has checked out")
802
+ p.add_argument('--json', action='store_true', help='print one JSON object, on failure too')
803
+ return p
804
+
805
+
806
+ def main(argv=None) -> int:
807
+ argv = list(sys.argv[1:] if argv is None else argv)
808
+ try:
809
+ args = parser().parse_args(argv)
810
+ except UsageError as error:
811
+ args = argparse.Namespace(run_dir=argv[0] if argv and not argv[0].startswith('-') else None, dry_run='--dry-run' in argv,
812
+ json='--json' in argv)
813
+ report = Report(args)
814
+ report.step('check', False, 'usage: %s' % error)
815
+ if not args.json:
816
+ parser().print_usage(sys.stderr)
817
+ return report.finish(REFUSED)
818
+ report = Report(args)
819
+ try:
820
+ code = run_command(args, report)
821
+ except Refused as error:
822
+ report.step('check', False, str(error))
823
+ code = REFUSED
824
+ except Busy as error:
825
+ report.step('lock', False, str(error))
826
+ code = BUSY
827
+ except Failed as error:
828
+ report.step('check', False, str(error))
829
+ code = FAILED
830
+ except KeyboardInterrupt:
831
+ report.step('interrupted', False, 'interrupted; see the steps above for what was done')
832
+ code = 130
833
+ except Exception as error: # noqa: BLE001 -- --json must still print one object
834
+ report.step('internal', False, '%s: %s' % (type(error).__name__, error))
835
+ code = FAILED
836
+ return report.finish(code)
837
+
838
+
839
+ if __name__ == '__main__':
840
+ sys.exit(main())