gabriel / muse public
sandbox.py python
175 lines 6.0 KB
Raw
sha256:f20267508f836150a1df05f57eaba390a49dae7412c1579a2c26beb3dfe94b50 feat(dev-safety): Phase 4 of #185 — disposable sandbox tool… Sonnet 5 patch 4 days ago
1 #!/usr/bin/env python3
2 """Disposable sandbox tooling for muse's own dev/testing (musehub staging #185 Phase 4).
3
4 Phase 3's guard rail (muse/cli/dev_guard.py) refuses mutating muse-dev
5 commands against canonical repos. This module is where that dev work is
6 supposed to actually happen instead: an APFS copy-on-write clone
7 (`cp -c -R`) of a canonical repo, timestamped and rotated, so testing
8 muse's own engine changes never risks the canonical object store — a full
9 clone costs almost nothing on APFS until the copy diverges from the
10 original.
11
12 Usage::
13
14 python3 sandbox.py refresh muse --json
15 python3 sandbox.py run muse -- status --json
16 """
17 from __future__ import annotations
18
19 import argparse
20 import json
21 import shutil
22 import subprocess
23 import sys
24 from datetime import datetime, timezone
25 from pathlib import Path
26
27 DEFAULT_CANONICAL_ROOTS = {
28 "muse": Path.home() / "ecosystem" / "muse",
29 "musehub": Path.home() / "ecosystem" / "musehub",
30 }
31 DEFAULT_SANDBOX_BASE = Path.home() / "dev" / "sandboxes"
32 DEFAULT_KEEP = 5
33
34
35 class SandboxNotFoundError(RuntimeError):
36 pass
37
38
39 def _sandbox_dir(repo: str, sandbox_base: Path) -> Path:
40 return sandbox_base / f"{repo}-sandbox"
41
42
43 def refresh_sandbox(
44 repo: str,
45 *,
46 canonical_root: Path | None = None,
47 sandbox_base: Path | None = None,
48 keep: int = DEFAULT_KEEP,
49 ) -> Path:
50 """Clone ``repo``'s canonical checkout into a new timestamped sandbox.
51
52 Updates the ``current`` symlink to point at the new clone and prunes
53 older timestamped clones beyond ``keep``. Returns the new sandbox path.
54 """
55 base = sandbox_base if sandbox_base is not None else DEFAULT_SANDBOX_BASE
56 if canonical_root is None:
57 if repo not in DEFAULT_CANONICAL_ROOTS:
58 raise ValueError(f"Unknown repo {repo!r} — no default canonical root registered for it.")
59 canonical_root = DEFAULT_CANONICAL_ROOTS[repo]
60 if not canonical_root.exists():
61 raise FileNotFoundError(f"Canonical repo root does not exist: {canonical_root}")
62
63 sandbox_dir = _sandbox_dir(repo, base)
64 sandbox_dir.mkdir(parents=True, exist_ok=True)
65
66 timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f")
67 dest = sandbox_dir / timestamp
68 if dest.exists():
69 raise RuntimeError(f"Sandbox destination already exists (clock collision?): {dest}")
70
71 subprocess.run(
72 ["cp", "-c", "-R", str(canonical_root), str(dest)],
73 check=True, capture_output=True, text=True,
74 )
75
76 current_link = sandbox_dir / "current"
77 if current_link.exists() or current_link.is_symlink():
78 current_link.unlink()
79 current_link.symlink_to(timestamp)
80
81 _prune_old_sandboxes(sandbox_dir, keep=keep)
82
83 return dest
84
85
86 def _prune_old_sandboxes(sandbox_dir: Path, *, keep: int) -> None:
87 timestamped_dirs = sorted(
88 (p for p in sandbox_dir.iterdir() if p.is_dir() and not p.is_symlink()),
89 key=lambda p: p.name,
90 )
91 for stale in timestamped_dirs[:-keep] if keep > 0 else timestamped_dirs:
92 shutil.rmtree(stale)
93
94
95 def resolve_current_sandbox(repo: str, *, sandbox_base: Path | None = None) -> Path:
96 base = sandbox_base if sandbox_base is not None else DEFAULT_SANDBOX_BASE
97 current_link = _sandbox_dir(repo, base) / "current"
98 if not current_link.exists():
99 raise SandboxNotFoundError(
100 f"No sandbox found for {repo!r} — run `sandbox-refresh.sh {repo}` first."
101 )
102 return current_link.resolve()
103
104
105 def _cmd_refresh(args: argparse.Namespace) -> int:
106 try:
107 path = refresh_sandbox(
108 args.repo,
109 sandbox_base=Path(args.sandbox_base) if args.sandbox_base else None,
110 keep=args.keep,
111 )
112 except (ValueError, FileNotFoundError, RuntimeError) as e:
113 print(f"❌ {e}", file=sys.stderr)
114 return 1
115 if args.json:
116 print(json.dumps({"repo": args.repo, "sandbox_path": str(path)}))
117 else:
118 print(f"✅ Refreshed sandbox for {args.repo}: {path}")
119 return 0
120
121
122 def _cmd_run(args: argparse.Namespace) -> int:
123 try:
124 sandbox_path = resolve_current_sandbox(
125 args.repo,
126 sandbox_base=Path(args.sandbox_base) if args.sandbox_base else None,
127 )
128 except SandboxNotFoundError as e:
129 print(f"❌ {e}", file=sys.stderr)
130 return 1
131
132 muse_dev = shutil.which("muse-dev")
133 if muse_dev is None:
134 print("❌ `muse-dev` not found on PATH.", file=sys.stderr)
135 return 1
136
137 proc = subprocess.run([muse_dev, "-C", str(sandbox_path), *args.muse_dev_args])
138 return proc.returncode
139
140
141 def main(argv: list[str] | None = None) -> int:
142 raw_argv = list(sys.argv[1:] if argv is None else argv)
143
144 # `run`'s trailing muse-dev args are split out manually on the first
145 # literal `--`, rather than via argparse.REMAINDER: REMAINDER greedily
146 # swallows *everything* from the first token after the leading positional
147 # (including our own --sandbox-base), regardless of where `--` appears.
148 muse_dev_args: list[str] = []
149 if raw_argv[:1] == ["run"] and "--" in raw_argv:
150 sep = raw_argv.index("--")
151 muse_dev_args = raw_argv[sep + 1:]
152 raw_argv = raw_argv[:sep]
153
154 parser = argparse.ArgumentParser(description=__doc__)
155 subparsers = parser.add_subparsers(dest="action", required=True)
156
157 refresh_p = subparsers.add_parser("refresh", help="Create a fresh sandbox clone of a canonical repo.")
158 refresh_p.add_argument("repo", help="Repo name, e.g. muse or musehub.")
159 refresh_p.add_argument("--sandbox-base", default=None, help=argparse.SUPPRESS)
160 refresh_p.add_argument("--keep", type=int, default=DEFAULT_KEEP)
161 refresh_p.add_argument("--json", action="store_true")
162 refresh_p.set_defaults(func=_cmd_refresh)
163
164 run_p = subparsers.add_parser("run", help="Run a muse-dev command against the current sandbox.")
165 run_p.add_argument("repo")
166 run_p.add_argument("--sandbox-base", default=None, help=argparse.SUPPRESS)
167 run_p.set_defaults(func=_cmd_run)
168
169 args = parser.parse_args(raw_argv)
170 args.muse_dev_args = muse_dev_args
171 return args.func(args)
172
173
174 if __name__ == "__main__":
175 sys.exit(main())
File History 1 commit
sha256:f20267508f836150a1df05f57eaba390a49dae7412c1579a2c26beb3dfe94b50 feat(dev-safety): Phase 4 of #185 — disposable sandbox tool… Sonnet 5 patch 4 days ago