#!/usr/bin/env python3 """Disposable sandbox tooling for muse's own dev/testing (musehub staging #185 Phase 4). Phase 3's guard rail (muse/cli/dev_guard.py) refuses mutating muse-dev commands against canonical repos. This module is where that dev work is supposed to actually happen instead: an APFS copy-on-write clone (`cp -c -R`) of a canonical repo, timestamped and rotated, so testing muse's own engine changes never risks the canonical object store — a full clone costs almost nothing on APFS until the copy diverges from the original. Usage:: python3 sandbox.py refresh muse --json python3 sandbox.py run muse -- status --json """ from __future__ import annotations import argparse import json import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path DEFAULT_CANONICAL_ROOTS = { "muse": Path.home() / "ecosystem" / "muse", "musehub": Path.home() / "ecosystem" / "musehub", } DEFAULT_SANDBOX_BASE = Path.home() / "dev" / "sandboxes" DEFAULT_KEEP = 5 class SandboxNotFoundError(RuntimeError): pass def _sandbox_dir(repo: str, sandbox_base: Path) -> Path: return sandbox_base / f"{repo}-sandbox" def refresh_sandbox( repo: str, *, canonical_root: Path | None = None, sandbox_base: Path | None = None, backup_base: Path | None = None, keep: int = DEFAULT_KEEP, ) -> Path: """Clone ``repo``'s canonical checkout into a new timestamped sandbox. Always takes a snapshot-store.sh backup of canonical first (#185 Phase 6) — cheap insurance, belt-and-suspenders even though the sandbox clone itself never touches canonical. Updates the ``current`` symlink to point at the new clone and prunes older timestamped clones beyond ``keep``. Returns the new sandbox path. """ base = sandbox_base if sandbox_base is not None else DEFAULT_SANDBOX_BASE if canonical_root is None: if repo not in DEFAULT_CANONICAL_ROOTS: raise ValueError(f"Unknown repo {repo!r} — no default canonical root registered for it.") canonical_root = DEFAULT_CANONICAL_ROOTS[repo] if not canonical_root.exists(): raise FileNotFoundError(f"Canonical repo root does not exist: {canonical_root}") from backup import create_snapshot create_snapshot(repo, canonical_root=canonical_root, backup_base=backup_base) sandbox_dir = _sandbox_dir(repo, base) sandbox_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f") dest = sandbox_dir / timestamp if dest.exists(): raise RuntimeError(f"Sandbox destination already exists (clock collision?): {dest}") subprocess.run( ["cp", "-c", "-R", str(canonical_root), str(dest)], check=True, capture_output=True, text=True, ) current_link = sandbox_dir / "current" if current_link.exists() or current_link.is_symlink(): current_link.unlink() current_link.symlink_to(timestamp) _prune_old_sandboxes(sandbox_dir, keep=keep) return dest def _prune_old_sandboxes(sandbox_dir: Path, *, keep: int) -> None: timestamped_dirs = sorted( (p for p in sandbox_dir.iterdir() if p.is_dir() and not p.is_symlink()), key=lambda p: p.name, ) for stale in timestamped_dirs[:-keep] if keep > 0 else timestamped_dirs: shutil.rmtree(stale) def resolve_current_sandbox(repo: str, *, sandbox_base: Path | None = None) -> Path: base = sandbox_base if sandbox_base is not None else DEFAULT_SANDBOX_BASE current_link = _sandbox_dir(repo, base) / "current" if not current_link.exists(): raise SandboxNotFoundError( f"No sandbox found for {repo!r} — run `sandbox-refresh.sh {repo}` first." ) return current_link.resolve() def _cmd_refresh(args: argparse.Namespace) -> int: try: path = refresh_sandbox( args.repo, sandbox_base=Path(args.sandbox_base) if args.sandbox_base else None, backup_base=Path(args.backup_base) if args.backup_base else None, keep=args.keep, ) except (ValueError, FileNotFoundError, RuntimeError) as e: print(f"❌ {e}", file=sys.stderr) return 1 if args.json: print(json.dumps({"repo": args.repo, "sandbox_path": str(path)})) else: print(f"✅ Refreshed sandbox for {args.repo}: {path}") return 0 def _cmd_run(args: argparse.Namespace) -> int: try: sandbox_path = resolve_current_sandbox( args.repo, sandbox_base=Path(args.sandbox_base) if args.sandbox_base else None, ) except SandboxNotFoundError as e: print(f"❌ {e}", file=sys.stderr) return 1 muse_dev = shutil.which("muse-dev") if muse_dev is None: print("❌ `muse-dev` not found on PATH.", file=sys.stderr) return 1 proc = subprocess.run([muse_dev, "-C", str(sandbox_path), *args.muse_dev_args]) return proc.returncode def main(argv: list[str] | None = None) -> int: raw_argv = list(sys.argv[1:] if argv is None else argv) # `run`'s trailing muse-dev args are split out manually on the first # literal `--`, rather than via argparse.REMAINDER: REMAINDER greedily # swallows *everything* from the first token after the leading positional # (including our own --sandbox-base), regardless of where `--` appears. muse_dev_args: list[str] = [] if raw_argv[:1] == ["run"] and "--" in raw_argv: sep = raw_argv.index("--") muse_dev_args = raw_argv[sep + 1:] raw_argv = raw_argv[:sep] parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="action", required=True) refresh_p = subparsers.add_parser("refresh", help="Create a fresh sandbox clone of a canonical repo.") refresh_p.add_argument("repo", help="Repo name, e.g. muse or musehub.") refresh_p.add_argument("--sandbox-base", default=None, help=argparse.SUPPRESS) refresh_p.add_argument("--backup-base", default=None, help=argparse.SUPPRESS) refresh_p.add_argument("--keep", type=int, default=DEFAULT_KEEP) refresh_p.add_argument("--json", action="store_true") refresh_p.set_defaults(func=_cmd_refresh) run_p = subparsers.add_parser("run", help="Run a muse-dev command against the current sandbox.") run_p.add_argument("repo") run_p.add_argument("--sandbox-base", default=None, help=argparse.SUPPRESS) run_p.set_defaults(func=_cmd_run) args = parser.parse_args(raw_argv) args.muse_dev_args = muse_dev_args return args.func(args) if __name__ == "__main__": sys.exit(main())