outerloop-science 0.1.0__py3-none-any.whl

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 (59) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +230 -0
  4. outerloop/appmanifest.py +203 -0
  5. outerloop/attempt.py +3784 -0
  6. outerloop/brief.py +528 -0
  7. outerloop/cli.py +621 -0
  8. outerloop/climbboard.py +1395 -0
  9. outerloop/compute.py +654 -0
  10. outerloop/contract.py +492 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +631 -0
  14. outerloop/evalcache.py +147 -0
  15. outerloop/followup.py +2172 -0
  16. outerloop/github.py +1531 -0
  17. outerloop/harness.py +1435 -0
  18. outerloop/housekeeping.py +151 -0
  19. outerloop/image.py +368 -0
  20. outerloop/init.py +744 -0
  21. outerloop/intake.py +126 -0
  22. outerloop/launchlog.py +239 -0
  23. outerloop/limits.py +80 -0
  24. outerloop/maintain.py +353 -0
  25. outerloop/maintain_agent_cli.py +81 -0
  26. outerloop/maintain_post_cli.py +140 -0
  27. outerloop/markers.py +48 -0
  28. outerloop/measure.py +529 -0
  29. outerloop/orchestrator.py +2011 -0
  30. outerloop/panel.py +188 -0
  31. outerloop/paths.py +40 -0
  32. outerloop/posting.py +160 -0
  33. outerloop/progress.py +170 -0
  34. outerloop/py.typed +0 -0
  35. outerloop/review.py +615 -0
  36. outerloop/review_agent.py +263 -0
  37. outerloop/review_agent_cli.py +209 -0
  38. outerloop/review_post_cli.py +162 -0
  39. outerloop/review_summarize_cli.py +165 -0
  40. outerloop/role_runner.py +229 -0
  41. outerloop/roles.py +274 -0
  42. outerloop/rolespec.py +91 -0
  43. outerloop/runstate.py +385 -0
  44. outerloop/steward.py +845 -0
  45. outerloop/style.py +12 -0
  46. outerloop/syscall.py +1192 -0
  47. outerloop/syscall_cli.py +762 -0
  48. outerloop/tick.py +3422 -0
  49. outerloop/verifier.py +403 -0
  50. outerloop/verify_agent.py +151 -0
  51. outerloop/verify_agent_cli.py +95 -0
  52. outerloop/verify_post_cli.py +116 -0
  53. outerloop/watcher.py +203 -0
  54. outerloop_science-0.1.0.dist-info/METADATA +152 -0
  55. outerloop_science-0.1.0.dist-info/RECORD +59 -0
  56. outerloop_science-0.1.0.dist-info/WHEEL +4 -0
  57. outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
  58. outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
  59. outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,151 @@
1
+ """Disk housekeeping: ended runs shed their workspaces.
2
+
3
+ A run's workspace (`ws/`, the clone the session worked in, and `ws-home/`,
4
+ the session's home with its tool caches) is the bulk of what a run leaves
5
+ on the state filesystem, in files far more than in bytes: 2026-09-03 the
6
+ scratch quota on Torch hit its 5M-file ceiling with 1.67M of them under
7
+ state/runs, and no tick could start for two hours. Everything the record
8
+ keeps for the research record lives outside those two directories: the
9
+ run's state.json, its report, its transcripts, and the ledger entries; the
10
+ tree itself is on GitHub (the PR branch, or the research line's snapshot).
11
+
12
+ Rules (docs/design/disk-maintenance.md):
13
+
14
+ - only ENDED runs shed, and only the two directories `ws` and `ws-home`;
15
+ - after a grace period (default 24 h, for post-mortems), oldest first;
16
+ - when the state filesystem's write probe FAILS, the grace is waived: the
17
+ tick sheds until the probe passes again, so a full quota heals itself;
18
+ - a workspace whose top-level entry is a symlink is not removed (a session
19
+ could aim it anywhere); it is logged and left for a human;
20
+ - the record notes when it shed (`workspace_shed`), so nothing runs twice.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ import re
28
+ import shutil
29
+ import time
30
+ from dataclasses import replace
31
+ from pathlib import Path
32
+
33
+ from outerloop.runstate import ENDED, RunRecord, load_record, run_dir, save_record
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ WORKSPACE_DIRS = ("ws", "ws-home")
38
+ DEFAULT_SHED_GRACE_S = 24 * 3600.0
39
+
40
+
41
+ def shed_workspace(root: Path, record: RunRecord, now: float) -> bool:
42
+ """Remove the run's `ws` and `ws-home` directories and stamp the record.
43
+ Returns False (and removes nothing) when either is a symlink."""
44
+ base = run_dir(root, record.run_id)
45
+ targets = [base / d for d in WORKSPACE_DIRS if (base / d).exists() or (base / d).is_symlink()]
46
+ for path in targets:
47
+ if path.is_symlink():
48
+ log.warning("not shedding %s: %s is a symlink", record.run_id, path.name)
49
+ return False
50
+ for path in targets:
51
+ shutil.rmtree(path, ignore_errors=True)
52
+ remaining = [p.name for p in targets if p.exists()]
53
+ if remaining:
54
+ log.warning("shed %s incompletely: %s remain", record.run_id, ", ".join(remaining))
55
+ return False
56
+ save_record(root, replace(record, workspace_shed=now), now)
57
+ return True
58
+
59
+
60
+ _TS_RE = re.compile(r"(\d{8}-\d{6})")
61
+
62
+
63
+ def _run_id_timestamp(run_id: str) -> str | None:
64
+ m = _TS_RE.search(run_id)
65
+ return m.group(1) if m else None
66
+
67
+
68
+ def _is_shed_candidate(
69
+ root: Path, record: RunRecord, now: float, grace_s: float, force: bool
70
+ ) -> bool:
71
+ if record.state != ENDED or record.workspace_shed:
72
+ return False
73
+ if not force and now - record.updated < grace_s:
74
+ return False
75
+ return any((run_dir(root, record.run_id) / d).exists() for d in WORKSPACE_DIRS)
76
+
77
+
78
+ def shed_ended_workspaces(
79
+ root: Path,
80
+ now: float,
81
+ *,
82
+ grace_s: float = DEFAULT_SHED_GRACE_S,
83
+ force: bool = False,
84
+ limit: int = 3,
85
+ time_budget_s: float = 120.0,
86
+ until_ok: object = None,
87
+ clock: object = None,
88
+ ) -> list[str]:
89
+ """Shed due workspaces until `limit` is reached or `time_budget_s`
90
+ elapses, checking the budget between runs; a forced sweep also stops when
91
+ `until_ok` reports a healthy disk.
92
+
93
+ Bounded by BOTH a count (`limit`) and a wall-clock budget
94
+ (`time_budget_s`). Removing a workspace is `rm -rf` over the state
95
+ filesystem, tens of thousands of tiny files each on a networked FS, so an
96
+ unbounded batch inside a tick can run for many minutes and blow the tick's
97
+ own timeout (2026-09-03: a 50-run batch, and reading every record to find
98
+ candidates, killed the tick before it could publish). Discovery is ONE
99
+ directory read, sorted oldest-first by the timestamp embedded in each run
100
+ id (no per-entry stat, no record load); the loop then loads one record at
101
+ a time and checks the budget EACH step, so both are bounded. The backlog
102
+ drains over several ticks. With `until_ok` a forced sweep also stops as
103
+ soon as the disk reports healthy."""
104
+ monotonic = clock if callable(clock) else time.monotonic
105
+ start = monotonic()
106
+ runs_root = root / "runs"
107
+ try:
108
+ # ONE directory read (no per-entry stat), sorted oldest-first by the
109
+ # timestamp every run id carries (`<name>-YYYYMMDD-HHMMSS-...`), which
110
+ # is chronological across benchmark prefixes where a lexical sort is
111
+ # not; ids without one sort last so they never block the backlog.
112
+ run_ids = sorted(
113
+ (e.name for e in os.scandir(runs_root)),
114
+ key=lambda name: (_run_id_timestamp(name) or "99999999-999999", name),
115
+ )
116
+ except OSError:
117
+ return []
118
+ # One readdir + an in-memory sort is cheap even for thousands of run dirs,
119
+ # but never start shedding if it somehow overran the budget: the tick then
120
+ # spends the rest of its time publishing, not deleting.
121
+ if monotonic() - start >= time_budget_s:
122
+ return []
123
+ shed: list[str] = []
124
+ for run_id in run_ids:
125
+ if len(shed) >= limit:
126
+ break
127
+ if monotonic() - start >= time_budget_s:
128
+ log.info(
129
+ "housekeeping: time budget (%.0fs) reached; %d shed this tick",
130
+ time_budget_s,
131
+ len(shed),
132
+ )
133
+ break
134
+ if until_ok is not None and callable(until_ok) and until_ok():
135
+ break
136
+ try:
137
+ record = load_record(root, run_id)
138
+ except (OSError, ValueError, TypeError, KeyError):
139
+ continue
140
+ if _is_shed_candidate(root, record, now, grace_s, force) and shed_workspace(
141
+ root, record, now
142
+ ):
143
+ shed.append(run_id)
144
+ if shed:
145
+ log.info(
146
+ "shed %d ended workspace(s)%s: %s",
147
+ len(shed),
148
+ " (forced)" if force else "",
149
+ ", ".join(shed),
150
+ )
151
+ return shed
outerloop/image.py ADDED
@@ -0,0 +1,368 @@
1
+ """The agent image on an adopter's machine: where it lives, and fetching the
2
+ published one. The tick reads `~/outerloop-images/agent-py312.sif` unless
3
+ `OUTERLOOP_IMAGE` names another file (tick._default_image); `init` fills that
4
+ default on Linux when Apptainer is installed, so local mode runs contained
5
+ without a hand step."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import os
11
+ import platform
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import tempfile
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+ from collections.abc import Callable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ IMAGE_NAME = "agent-py312.sif"
24
+ IMAGE_URL = f"https://huggingface.co/outerloop-science/agent-image/resolve/main/{IMAGE_NAME}"
25
+ IMAGE_DIR_NAME = "outerloop-images"
26
+ LEGACY_IMAGE_DIR_NAME = "autoresearch-images" # pre-rename; honored until the release after 0.1
27
+ _CHUNK = 1 << 20
28
+ _PROBE_TIMEOUT_S = 60
29
+ APPTAINER_RELEASES = "https://github.com/apptainer/apptainer/releases"
30
+ APPTAINER_VERSION = "1.5.3"
31
+ # the GitHub release ships Debian packages for x86-64 only; the project's PPA
32
+ # builds amd64 and arm64 for every current Ubuntu
33
+ APPTAINER_DEB = f"apptainer_{APPTAINER_VERSION}_amd64.deb"
34
+ APPTAINER_DEB_URL = f"{APPTAINER_RELEASES}/download/v{APPTAINER_VERSION}/{APPTAINER_DEB}"
35
+ APPTAINER_PPA = "ppa:apptainer/ppa"
36
+ # the unprivileged installer, pinned to the release tag (never a moving branch)
37
+ APPTAINER_UNPRIV_URL = (
38
+ "https://raw.githubusercontent.com/apptainer/apptainer/"
39
+ f"v{APPTAINER_VERSION}/tools/install-unprivileged.sh"
40
+ )
41
+ PROBE_CMD = "apptainer exec docker://alpine:3.20 cat /etc/alpine-release"
42
+
43
+
44
+ def image_dir(home: Path | None = None) -> Path:
45
+ return (home or Path.home()) / IMAGE_DIR_NAME
46
+
47
+
48
+ def find_image(home: Path | None = None) -> str:
49
+ """An existing image on this machine, new location first, or ""."""
50
+ home = home or Path.home()
51
+ for d in (IMAGE_DIR_NAME, LEGACY_IMAGE_DIR_NAME):
52
+ p = home / d / IMAGE_NAME
53
+ if p.is_file():
54
+ return str(p)
55
+ return ""
56
+
57
+
58
+ def _linux_flavor() -> tuple[str, str]:
59
+ """(distribution id, version) from os-release, or ("", "")."""
60
+ try:
61
+ info = platform.freedesktop_os_release()
62
+ except OSError:
63
+ return "", ""
64
+ return info.get("ID", ""), info.get("VERSION_ID", "")
65
+
66
+
67
+ def install_hint() -> str:
68
+ """The exact steps for THIS machine, written for someone installing Apptainer
69
+ for the first time. Every path is a root operation or an admin request, which
70
+ is why init never runs it."""
71
+ if sys.platform == "darwin":
72
+ return (
73
+ "macOS has no Apptainer; runs stay uncontained here (a macOS containment is on the\n"
74
+ " roadmap). A Linux machine, or Slurm, runs contained."
75
+ )
76
+ dist, _version = _linux_flavor()
77
+ apparmor_note = (
78
+ " Ubuntu 23.10+ blocks unprivileged user namespaces unless an AppArmor profile allows\n"
79
+ " them; the official package carries that profile (the unprivileged installer does\n"
80
+ " not, and fails with 'Could not write info to setgroups').\n"
81
+ )
82
+ check = (
83
+ f" 3. {PROBE_CMD}\n"
84
+ " (prints a version number)\n"
85
+ " then run `outerloop init --force` again to download the image."
86
+ )
87
+ if dist in ("ubuntu", "linuxmint", "pop"):
88
+ return (
89
+ "Install Apptainer from the project's Ubuntu PPA (amd64 and arm64):\n"
90
+ + apparmor_note
91
+ + f" 1. sudo add-apt-repository -y {APPTAINER_PPA}\n"
92
+ " 2. sudo apt-get update && sudo apt-get install -y apptainer\n" + check
93
+ )
94
+ if dist == "debian":
95
+ if platform.machine() == "x86_64":
96
+ return (
97
+ "Install the official Apptainer package (Debian, x86-64):\n"
98
+ + apparmor_note
99
+ + f" 1. curl -fsSLO {APPTAINER_DEB_URL}\n"
100
+ f" 2. sudo apt-get install -y ./{APPTAINER_DEB}\n" + check
101
+ )
102
+ return (
103
+ f"No Apptainer Debian package is published for this machine ({platform.machine()}).\n"
104
+ " Build from source (https://apptainer.org/docs/admin/main/installation.html) or use\n"
105
+ " the unprivileged installer. Download it, read it, then run it:\n"
106
+ f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
107
+ " less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
108
+ " export PATH=$HOME/apptainer/bin:$PATH\n"
109
+ f" Check with `{PROBE_CMD}`, then run `outerloop init --force` again."
110
+ )
111
+ if dist == "fedora":
112
+ return (
113
+ "Install Apptainer from the Fedora repositories:\n"
114
+ " sudo dnf install -y apptainer\n"
115
+ f" {PROBE_CMD} # prints a version number\n"
116
+ " then run `outerloop init --force` again to download the image."
117
+ )
118
+ if dist in ("rhel", "centos", "rocky", "almalinux"):
119
+ return (
120
+ "Install Apptainer from EPEL:\n"
121
+ " sudo dnf install -y epel-release\n"
122
+ " sudo dnf install -y apptainer\n"
123
+ f" {PROBE_CMD} # prints a version number\n"
124
+ " then run `outerloop init --force` again to download the image."
125
+ )
126
+ return (
127
+ "Install Apptainer for your distribution:\n"
128
+ " https://apptainer.org/docs/admin/main/installation.html\n"
129
+ " Without root, the project's unprivileged installer works on most systems.\n"
130
+ " Download it, read it, then run it:\n"
131
+ f" curl -fsSLO {APPTAINER_UNPRIV_URL}\n"
132
+ " less install-unprivileged.sh && bash install-unprivileged.sh ~/apptainer\n"
133
+ " export PATH=$HOME/apptainer/bin:$PATH\n"
134
+ " On a Slurm cluster, ask the administrators; most already provide it.\n"
135
+ f" Check with `{PROBE_CMD}`, then run\n"
136
+ " `outerloop init --force` again to download the image."
137
+ )
138
+
139
+
140
+ def containment_check(*, runner: Callable[..., Any] = subprocess.run) -> str:
141
+ """Why contained runs are NOT possible here, or "" when they are. Finds
142
+ apptainer and actually runs a container: being on PATH is not enough
143
+ (Ubuntu 24.04 lets any program create a user namespace but strips its
144
+ capabilities unless an AppArmor profile allows it, so a hand-installed
145
+ apptainer fails at exec time with `Could not write info to setgroups`).
146
+ The probe needs no network: a throwaway sandbox with the standard mount
147
+ points, the host's /bin, /lib and /usr bound in, running /bin/true —
148
+ about 0.1 s on a working install."""
149
+ if sys.platform == "darwin":
150
+ return "apptainer does not exist on macOS"
151
+ if not sys.platform.startswith("linux"):
152
+ return f"apptainer is not available on {sys.platform}"
153
+ binary = shutil.which("apptainer")
154
+ if binary is None:
155
+ return "apptainer is not installed (not on PATH)"
156
+ binds = ",".join(d for d in ("/bin", "/lib", "/lib64", "/usr") if os.path.exists(d))
157
+ with tempfile.TemporaryDirectory(prefix="outerloop-probe-") as sandbox:
158
+ for d in (
159
+ "dev",
160
+ "proc",
161
+ "sys",
162
+ "tmp",
163
+ "etc",
164
+ "bin",
165
+ "lib",
166
+ "lib64",
167
+ "usr",
168
+ "root",
169
+ "home",
170
+ "var/tmp",
171
+ ):
172
+ os.makedirs(os.path.join(sandbox, d), exist_ok=True)
173
+ for f in ("etc/passwd", "etc/group"):
174
+ open(os.path.join(sandbox, f), "a").close()
175
+ argv = [binary, "exec", "--containall", "--cleanenv", "--no-home", "--pwd", "/"]
176
+ if binds:
177
+ argv += ["-B", binds]
178
+ argv += [sandbox, "/bin/true"]
179
+ try:
180
+ proc = runner(argv, capture_output=True, text=True, timeout=_PROBE_TIMEOUT_S)
181
+ except (OSError, subprocess.TimeoutExpired) as exc:
182
+ return f"apptainer failed to start: {exc}"
183
+ err = (proc.stderr or "").strip()
184
+ if "setgroups" in err or "user namespace" in err.lower() or "unshare" in err.lower():
185
+ last = err.splitlines()[-1] if err else "user namespace refused"
186
+ return f"apptainer cannot create containers here ({last})"
187
+ if proc.returncode != 0:
188
+ last = err.splitlines()[-1] if err else f"exit {proc.returncode}"
189
+ return f"apptainer exec failed ({last})"
190
+ return ""
191
+
192
+
193
+ def _fmt_mb(n: int) -> str:
194
+ return f"{n / (1 << 20):,.0f} MB"
195
+
196
+
197
+ class _Progress:
198
+ """Download progress: a redrawn bar with size, speed and ETA when stdout is
199
+ a terminal; one line per 10% when it is not (a log, a CI step). `report`
200
+ receives the non-tty lines and the final summary."""
201
+
202
+ def __init__(self, url: str, report: Callable[[str], None]) -> None:
203
+ self.url = url
204
+ self.report = report
205
+ self.total = 0
206
+ self.t0 = 0.0
207
+ self.tty = sys.stdout.isatty()
208
+ self.next_mark = 10
209
+ self.last_draw = 0.0
210
+
211
+ def start(self, total: int) -> None:
212
+ self.total = total
213
+ self.t0 = time.monotonic()
214
+ size = f" ({_fmt_mb(total)})" if total else ""
215
+ self.report(f"downloading {self.url}{size}")
216
+
217
+ def update(self, done: int) -> None:
218
+ now = time.monotonic()
219
+ if self.tty:
220
+ if now - self.last_draw < 0.2:
221
+ return
222
+ self.last_draw = now
223
+ sys.stdout.write("\r" + self._line(done, now) + "\033[K")
224
+ sys.stdout.flush()
225
+ elif self.total:
226
+ pct = done * 100 // self.total
227
+ while pct >= self.next_mark and self.next_mark <= 100:
228
+ self.report(f" {self.next_mark:3d}% {_fmt_mb(done)} of {_fmt_mb(self.total)}")
229
+ self.next_mark += 10
230
+
231
+ def _line(self, done: int, now: float) -> str:
232
+ elapsed = max(now - self.t0, 1e-6)
233
+ speed = done / elapsed
234
+ if self.total:
235
+ frac = min(done / self.total, 1.0)
236
+ width = 30
237
+ filled = int(frac * width)
238
+ bar = "#" * filled + "-" * (width - filled)
239
+ eta = (self.total - done) / speed if speed > 0 else 0
240
+ return (
241
+ f" [{bar}] {frac * 100:3.0f}% {_fmt_mb(done)} of {_fmt_mb(self.total)}"
242
+ f" {speed / (1 << 20):.1f} MB/s ETA {int(eta) // 60}:{int(eta) % 60:02d}"
243
+ )
244
+ return f" {_fmt_mb(done)} {speed / (1 << 20):.1f} MB/s"
245
+
246
+ def finish(self, done: int) -> None:
247
+ if self.tty:
248
+ sys.stdout.write("\r" + self._line(done, time.monotonic()) + "\033[K\n")
249
+ sys.stdout.flush()
250
+ elapsed = time.monotonic() - self.t0
251
+ self.report(f" downloaded {_fmt_mb(done)} in {int(elapsed) // 60}:{int(elapsed) % 60:02d}")
252
+
253
+ def abort(self) -> None:
254
+ if self.tty:
255
+ sys.stdout.write("\n")
256
+ sys.stdout.flush()
257
+
258
+
259
+ def download_image(
260
+ url: str = IMAGE_URL,
261
+ dest: Path | None = None,
262
+ *,
263
+ opener: Callable[..., Any] = urllib.request.urlopen,
264
+ report: Callable[[str], None] = print,
265
+ checksum_url: str = "",
266
+ ) -> Path:
267
+ """Stream `url` to `dest` (default: the image dir), through a `.part`
268
+ file renamed into place only after its sha256 matches the checksum the
269
+ build published beside it (`<url>.sha256`); any failure, mismatch or
270
+ interruption removes the part file. Progress: a live bar on a terminal,
271
+ one line per 10% otherwise."""
272
+ dest = dest or image_dir() / IMAGE_NAME
273
+ dest.parent.mkdir(parents=True, exist_ok=True)
274
+ part = dest.with_name(dest.name + ".part")
275
+ digest = hashlib.sha256()
276
+ bar = _Progress(url, report)
277
+ try:
278
+ with opener(url, timeout=60) as resp:
279
+ total = int(resp.headers.get("Content-Length") or 0)
280
+ bar.start(total)
281
+ done = 0
282
+ with part.open("wb") as out:
283
+ while True:
284
+ chunk = resp.read(_CHUNK)
285
+ if not chunk:
286
+ break
287
+ out.write(chunk)
288
+ digest.update(chunk)
289
+ done += len(chunk)
290
+ bar.update(done)
291
+ bar.finish(done)
292
+ if total and done != total:
293
+ raise OSError(f"short download: {done} of {total} bytes")
294
+ # the checksum the build published beside the image: an image that does
295
+ # not match it is never installed (it would run with the bound harness
296
+ # binary and the run's credentials)
297
+ expected = _published_checksum(checksum_url or url + ".sha256", opener)
298
+ if digest.hexdigest() != expected:
299
+ raise OSError(
300
+ f"checksum mismatch for {url}: expected {expected}, got {digest.hexdigest()}"
301
+ )
302
+ report(" checksum verified")
303
+ except BaseException:
304
+ # a failed or interrupted download never leaves a partial image where
305
+ # a later run would find it
306
+ bar.abort()
307
+ part.unlink(missing_ok=True)
308
+ raise
309
+ os.replace(part, dest)
310
+ report(f" saved {dest}")
311
+ return dest
312
+
313
+
314
+ def _published_checksum(url: str, opener: Callable[..., Any]) -> str:
315
+ """The sha256 hex the build published at `url` (`<hex> <file>` as sha256sum
316
+ writes it). No readable checksum means no image."""
317
+ try:
318
+ with opener(url, timeout=60) as resp:
319
+ text = resp.read(4096).decode("ascii", "replace")
320
+ except (OSError, urllib.error.URLError) as exc:
321
+ raise OSError(f"no checksum published at {url}: {exc}") from exc
322
+ token = text.split()[0].strip().lower() if text.split() else ""
323
+ if len(token) != 64 or any(c not in "0123456789abcdef" for c in token):
324
+ raise OSError(f"unreadable checksum at {url}")
325
+ return token
326
+
327
+
328
+ def ensure_image(
329
+ *,
330
+ interactive: bool,
331
+ want: bool = True,
332
+ probe: bool = True,
333
+ home: Path | None = None,
334
+ ask: Callable[[str], str] = input,
335
+ report: Callable[[str], None] = print,
336
+ fetch: Callable[..., Path] = download_image,
337
+ ) -> str:
338
+ """The image path `init` records: an existing image, else the published
339
+ one downloaded to the image dir, when this machine can run it and the
340
+ adopter did not opt out. "" means uncontained (the loop says so at start);
341
+ when that is because Apptainer is missing or cannot run containers, the
342
+ exact install steps for this machine are printed, and an image already on
343
+ disk is NOT recorded (a contained run would only fail later). `probe=False`
344
+ skips the container check: on Slurm the image runs on compute nodes, which
345
+ the login node cannot speak for. A failed download is a warning, never a
346
+ failed setup."""
347
+ if not want:
348
+ return ""
349
+ problem = containment_check() if probe else ""
350
+ found = find_image(home)
351
+ if problem:
352
+ report(f"Runs will be UNCONTAINED on this machine: {problem}.")
353
+ report(" " + install_hint())
354
+ if found:
355
+ report(f" (the image at {found} will be used once apptainer works)")
356
+ return ""
357
+ if found:
358
+ return found
359
+ dest = image_dir(home) / IMAGE_NAME
360
+ if interactive:
361
+ answer = ask(f"Download the agent image to {dest} so runs are contained? [Y/n] ")
362
+ if answer.strip().lower().startswith("n"):
363
+ return ""
364
+ try:
365
+ return str(fetch(IMAGE_URL, dest, report=report))
366
+ except (OSError, urllib.error.URLError) as exc:
367
+ report(f" image download failed ({exc}); continuing without one, pass --image later")
368
+ return ""