@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.
- package/CHANGELOG.md +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -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 +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -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 +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -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 +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- 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())
|