maintenance.py
python
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b
feat: supercharge muse blame + remove --porcelain everywhere
Human
patch
164 days ago
| 1 | """``muse maintenance`` — scheduled store maintenance orchestration. |
| 2 | |
| 3 | Orchestrates multiple store-health tasks under a single command with a shared |
| 4 | schedule configuration and persistent run-history. |
| 5 | |
| 6 | Subcommands |
| 7 | ----------- |
| 8 | ``run [--task <task>]... [--all] [--dry-run] [--json]`` |
| 9 | Execute one or more maintenance tasks. Available tasks: |
| 10 | |
| 11 | ``gc`` |
| 12 | Remove unreachable objects, commits, and snapshots from the store |
| 13 | (wraps :func:`muse.core.gc.run_gc` with ``full=True``). |
| 14 | |
| 15 | ``verify-objects`` |
| 16 | Rehash every object in the store and report any whose content does not |
| 17 | match their filename. Non-destructive; always safe to run. |
| 18 | |
| 19 | Without ``--task``, runs the tasks listed in the persisted schedule config |
| 20 | (default: ``gc`` only). ``--all`` runs every known task. |
| 21 | |
| 22 | Unless ``--dry-run`` is set, each task's completion time is written back |
| 23 | to ``.muse/maintenance.json`` so ``status`` can report freshness. |
| 24 | |
| 25 | ``status [--json]`` |
| 26 | Show the schedule configuration and the last-run timestamp for each task. |
| 27 | |
| 28 | ``schedule [--period-hours N] [--enable] [--disable]`` |
| 29 | Write or update the schedule configuration in ``.muse/maintenance.json``. |
| 30 | ``--period-hours`` sets the suggested inter-run interval (informational — |
| 31 | Muse does not run background daemons; callers use this value to decide |
| 32 | whether to trigger ``run``). |
| 33 | |
| 34 | State |
| 35 | ----- |
| 36 | All state is stored in ``.muse/maintenance.json``:: |
| 37 | |
| 38 | { |
| 39 | "enabled": true, |
| 40 | "period_hours": 24, |
| 41 | "tasks": ["gc", "verify-objects"], |
| 42 | "last_run": { |
| 43 | "gc": "2026-04-14T12:00:00+00:00", |
| 44 | "verify-objects": "2026-04-14T12:00:00+00:00" |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | Exit codes:: |
| 49 | |
| 50 | 0 — all tasks completed (even if verify-objects found issues) |
| 51 | 1 — a task argument was invalid |
| 52 | 2 — usage error |
| 53 | """ |
| 54 | |
| 55 | from __future__ import annotations |
| 56 | |
| 57 | import argparse |
| 58 | import datetime |
| 59 | import json as _json |
| 60 | import logging |
| 61 | import sys |
| 62 | import time |
| 63 | from typing import Any |
| 64 | |
| 65 | from muse.core.errors import ExitCode |
| 66 | from muse.core.gc import _DEFAULT_GRACE_PERIOD_SECONDS, run_gc |
| 67 | from muse.core.repo import require_repo |
| 68 | |
| 69 | logger = logging.getLogger(__name__) |
| 70 | |
| 71 | # --------------------------------------------------------------------------- |
| 72 | # Constants |
| 73 | # --------------------------------------------------------------------------- |
| 74 | |
| 75 | _ALL_TASKS: list[str] = ["gc", "verify-objects"] |
| 76 | _DEFAULT_TASKS: list[str] = ["gc"] |
| 77 | _DEFAULT_PERIOD_HOURS: int = 24 |
| 78 | _CONFIG_FILE = "maintenance.json" |
| 79 | |
| 80 | |
| 81 | # --------------------------------------------------------------------------- |
| 82 | # Config I/O |
| 83 | # --------------------------------------------------------------------------- |
| 84 | |
| 85 | |
| 86 | def _config_path(root) -> "pathlib.Path": |
| 87 | import pathlib |
| 88 | return root / ".muse" / _CONFIG_FILE |
| 89 | |
| 90 | |
| 91 | def _read_config(root) -> dict[str, Any]: |
| 92 | path = _config_path(root) |
| 93 | if not path.exists(): |
| 94 | return { |
| 95 | "enabled": True, |
| 96 | "period_hours": _DEFAULT_PERIOD_HOURS, |
| 97 | "tasks": list(_DEFAULT_TASKS), |
| 98 | "last_run": {}, |
| 99 | } |
| 100 | try: |
| 101 | return _json.loads(path.read_text(encoding="utf-8")) |
| 102 | except (OSError, _json.JSONDecodeError): |
| 103 | return { |
| 104 | "enabled": True, |
| 105 | "period_hours": _DEFAULT_PERIOD_HOURS, |
| 106 | "tasks": list(_DEFAULT_TASKS), |
| 107 | "last_run": {}, |
| 108 | } |
| 109 | |
| 110 | |
| 111 | def _write_config(root, cfg: dict[str, Any]) -> None: |
| 112 | _config_path(root).write_text(_json.dumps(cfg, indent=2), encoding="utf-8") |
| 113 | |
| 114 | |
| 115 | def _now_iso() -> str: |
| 116 | return datetime.datetime.now(datetime.timezone.utc).isoformat() |
| 117 | |
| 118 | |
| 119 | # --------------------------------------------------------------------------- |
| 120 | # Task implementations |
| 121 | # --------------------------------------------------------------------------- |
| 122 | |
| 123 | |
| 124 | def _run_gc(root, *, dry_run: bool = False) -> dict[str, Any]: |
| 125 | """Run garbage collection; return a result summary dict.""" |
| 126 | t0 = time.monotonic() |
| 127 | result = run_gc( |
| 128 | root, |
| 129 | dry_run=dry_run, |
| 130 | grace_period_seconds=0 if dry_run else _DEFAULT_GRACE_PERIOD_SECONDS, |
| 131 | full=True, |
| 132 | ) |
| 133 | return { |
| 134 | "collected_count": result.collected_count, |
| 135 | "collected_bytes": result.collected_bytes, |
| 136 | "reachable_count": result.reachable_count, |
| 137 | "commits_collected": result.commits_collected, |
| 138 | "snapshots_collected": result.snapshots_collected, |
| 139 | "dry_run": dry_run, |
| 140 | "elapsed_seconds": time.monotonic() - t0, |
| 141 | } |
| 142 | |
| 143 | |
| 144 | def _run_verify_objects(root) -> dict[str, Any]: |
| 145 | """Rehash every object; return summary with checked/failed counts.""" |
| 146 | import hashlib |
| 147 | import pathlib |
| 148 | |
| 149 | t0 = time.monotonic() |
| 150 | objects_dir = root / ".muse" / "objects" |
| 151 | checked = 0 |
| 152 | failed = 0 |
| 153 | failures: list[str] = [] |
| 154 | |
| 155 | if not objects_dir.exists(): |
| 156 | return { |
| 157 | "checked": 0, |
| 158 | "failed": 0, |
| 159 | "failures": [], |
| 160 | "elapsed_seconds": time.monotonic() - t0, |
| 161 | } |
| 162 | |
| 163 | for prefix_dir in sorted(objects_dir.iterdir()): |
| 164 | if not prefix_dir.is_dir() or len(prefix_dir.name) != 2: |
| 165 | continue |
| 166 | for obj_file in sorted(prefix_dir.iterdir()): |
| 167 | if not obj_file.is_file(): |
| 168 | continue |
| 169 | expected_id = prefix_dir.name + obj_file.name |
| 170 | if len(expected_id) != 64: |
| 171 | continue |
| 172 | checked += 1 |
| 173 | try: |
| 174 | h = hashlib.sha256() |
| 175 | with obj_file.open("rb") as fh: |
| 176 | for chunk in iter(lambda: fh.read(65536), b""): |
| 177 | h.update(chunk) |
| 178 | actual = h.hexdigest() |
| 179 | if actual != expected_id: |
| 180 | failed += 1 |
| 181 | failures.append(expected_id) |
| 182 | except OSError: |
| 183 | failed += 1 |
| 184 | failures.append(expected_id) |
| 185 | |
| 186 | return { |
| 187 | "checked": checked, |
| 188 | "failed": failed, |
| 189 | "failures": failures[:20], # cap to avoid huge JSON |
| 190 | "elapsed_seconds": time.monotonic() - t0, |
| 191 | } |
| 192 | |
| 193 | |
| 194 | # --------------------------------------------------------------------------- |
| 195 | # Subcommand handlers |
| 196 | # --------------------------------------------------------------------------- |
| 197 | |
| 198 | |
| 199 | _TASK_RUNNERS = { |
| 200 | "gc": _run_gc, |
| 201 | "verify-objects": _run_verify_objects, |
| 202 | } |
| 203 | |
| 204 | |
| 205 | def _cmd_run(args: argparse.Namespace, root) -> None: |
| 206 | dry_run: bool = args.dry_run |
| 207 | output_json: bool = args.output_json |
| 208 | |
| 209 | # Validate requested tasks. |
| 210 | if args.all: |
| 211 | tasks_to_run = list(_ALL_TASKS) |
| 212 | elif args.task: |
| 213 | tasks_to_run = list(args.task) |
| 214 | unknown = [t for t in tasks_to_run if t not in _TASK_RUNNERS] |
| 215 | if unknown: |
| 216 | print( |
| 217 | f"❌ Unknown task(s): {', '.join(unknown)}. " |
| 218 | f"Available: {', '.join(_ALL_TASKS)}", |
| 219 | file=sys.stderr, |
| 220 | ) |
| 221 | raise SystemExit(ExitCode.USER_ERROR) |
| 222 | else: |
| 223 | cfg = _read_config(root) |
| 224 | tasks_to_run = cfg.get("tasks") or list(_DEFAULT_TASKS) |
| 225 | |
| 226 | t0_total = time.monotonic() |
| 227 | results: dict[str, Any] = {} |
| 228 | |
| 229 | for task in tasks_to_run: |
| 230 | runner_fn = _TASK_RUNNERS[task] |
| 231 | if task == "gc": |
| 232 | results[task] = runner_fn(root, dry_run=dry_run) |
| 233 | else: |
| 234 | results[task] = runner_fn(root) |
| 235 | |
| 236 | elapsed = time.monotonic() - t0_total |
| 237 | |
| 238 | # Persist timestamps (skipped in dry-run). |
| 239 | if not dry_run: |
| 240 | cfg = _read_config(root) |
| 241 | now = _now_iso() |
| 242 | if "last_run" not in cfg: |
| 243 | cfg["last_run"] = {} |
| 244 | for task in tasks_to_run: |
| 245 | cfg["last_run"][task] = now |
| 246 | _write_config(root, cfg) |
| 247 | |
| 248 | payload: dict[str, Any] = { |
| 249 | "tasks_run": tasks_to_run, |
| 250 | "results": results, |
| 251 | "dry_run": dry_run, |
| 252 | "elapsed_seconds": round(elapsed, 4), |
| 253 | } |
| 254 | |
| 255 | if output_json: |
| 256 | print(_json.dumps(payload)) |
| 257 | else: |
| 258 | prefix = "[dry-run] " if dry_run else "" |
| 259 | for task in tasks_to_run: |
| 260 | r = results[task] |
| 261 | if task == "gc": |
| 262 | action = "Would remove" if dry_run else "Removed" |
| 263 | print( |
| 264 | f"{prefix}{action} {r['collected_count']} unreachable object(s) " |
| 265 | f"({r['collected_bytes']} bytes); " |
| 266 | f"{r['reachable_count']} reachable" |
| 267 | ) |
| 268 | elif task == "verify-objects": |
| 269 | status = "✅" if r["failed"] == 0 else "❌" |
| 270 | print( |
| 271 | f"{prefix}{status} verify-objects: " |
| 272 | f"{r['checked']} checked, {r['failed']} failed" |
| 273 | ) |
| 274 | print( |
| 275 | f"{prefix}Done in {elapsed:.3f}s" |
| 276 | + (" (no changes written)" if dry_run else "") |
| 277 | ) |
| 278 | |
| 279 | |
| 280 | def _cmd_status(args: argparse.Namespace, root) -> None: |
| 281 | cfg = _read_config(root) |
| 282 | output_json: bool = args.output_json |
| 283 | |
| 284 | enabled = cfg.get("enabled", True) |
| 285 | period_hours = cfg.get("period_hours", _DEFAULT_PERIOD_HOURS) |
| 286 | last_run: dict[str, str] = cfg.get("last_run", {}) |
| 287 | |
| 288 | if output_json: |
| 289 | print(_json.dumps({ |
| 290 | "enabled": enabled, |
| 291 | "period_hours": period_hours, |
| 292 | "tasks": cfg.get("tasks", list(_DEFAULT_TASKS)), |
| 293 | "last_run": last_run, |
| 294 | })) |
| 295 | return |
| 296 | |
| 297 | status_label = "enabled" if enabled else "disabled" |
| 298 | print(f"Maintenance: {status_label} (period: {period_hours}h)") |
| 299 | if not last_run: |
| 300 | print(" Never run.") |
| 301 | else: |
| 302 | for task, ts in sorted(last_run.items()): |
| 303 | print(f" {task:<20} last run: {ts}") |
| 304 | |
| 305 | |
| 306 | def _cmd_schedule(args: argparse.Namespace, root) -> None: |
| 307 | period_hours: int | None = args.period_hours |
| 308 | enable: bool = args.enable |
| 309 | disable: bool = args.disable |
| 310 | |
| 311 | if period_hours is not None and period_hours < 0: |
| 312 | print("❌ --period-hours must be ≥ 0", file=sys.stderr) |
| 313 | raise SystemExit(ExitCode.USER_ERROR) |
| 314 | |
| 315 | cfg = _read_config(root) |
| 316 | |
| 317 | if period_hours is not None: |
| 318 | cfg["period_hours"] = period_hours |
| 319 | if enable: |
| 320 | cfg["enabled"] = True |
| 321 | if disable: |
| 322 | cfg["enabled"] = False |
| 323 | |
| 324 | _write_config(root, cfg) |
| 325 | status = "enabled" if cfg.get("enabled", True) else "disabled" |
| 326 | ph = cfg.get("period_hours", _DEFAULT_PERIOD_HOURS) |
| 327 | print(f"Maintenance schedule updated: {status}, period: {ph}h") |
| 328 | |
| 329 | |
| 330 | # --------------------------------------------------------------------------- |
| 331 | # Registration |
| 332 | # --------------------------------------------------------------------------- |
| 333 | |
| 334 | |
| 335 | def register( |
| 336 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 337 | ) -> None: |
| 338 | """Register the ``muse maintenance`` subcommand.""" |
| 339 | parser = subparsers.add_parser( |
| 340 | "maintenance", |
| 341 | help="Scheduled store maintenance orchestration.", |
| 342 | description=__doc__, |
| 343 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 344 | ) |
| 345 | sub = parser.add_subparsers(dest="maint_command", metavar="SUBCOMMAND") |
| 346 | sub.required = True |
| 347 | |
| 348 | # run |
| 349 | p_run = sub.add_parser( |
| 350 | "run", |
| 351 | help="Run maintenance tasks.", |
| 352 | ) |
| 353 | p_run.add_argument( |
| 354 | "--task", |
| 355 | action="append", |
| 356 | metavar="TASK", |
| 357 | choices=_ALL_TASKS, |
| 358 | dest="task", |
| 359 | help=f"Task to run (repeatable). Choices: {', '.join(_ALL_TASKS)}", |
| 360 | ) |
| 361 | p_run.add_argument( |
| 362 | "--all", |
| 363 | action="store_true", |
| 364 | default=False, |
| 365 | help="Run all maintenance tasks.", |
| 366 | ) |
| 367 | p_run.add_argument( |
| 368 | "--dry-run", |
| 369 | action="store_true", |
| 370 | default=False, |
| 371 | dest="dry_run", |
| 372 | help="Preview without making changes or updating timestamps.", |
| 373 | ) |
| 374 | p_run.add_argument( |
| 375 | "--json", |
| 376 | action="store_true", |
| 377 | default=False, |
| 378 | dest="output_json", |
| 379 | help="Emit a JSON result object.", |
| 380 | ) |
| 381 | p_run.set_defaults(maint_func=_cmd_run) |
| 382 | |
| 383 | # status |
| 384 | p_status = sub.add_parser("status", help="Show schedule and last-run info.") |
| 385 | p_status.add_argument( |
| 386 | "--json", |
| 387 | action="store_true", |
| 388 | default=False, |
| 389 | dest="output_json", |
| 390 | ) |
| 391 | p_status.set_defaults(maint_func=_cmd_status) |
| 392 | |
| 393 | # schedule |
| 394 | p_sched = sub.add_parser("schedule", help="Configure maintenance schedule.") |
| 395 | p_sched.add_argument( |
| 396 | "--period-hours", |
| 397 | type=int, |
| 398 | default=None, |
| 399 | metavar="N", |
| 400 | dest="period_hours", |
| 401 | help="Suggested interval between runs (hours).", |
| 402 | ) |
| 403 | p_sched.add_argument( |
| 404 | "--enable", |
| 405 | action="store_true", |
| 406 | default=False, |
| 407 | help="Mark maintenance as enabled.", |
| 408 | ) |
| 409 | p_sched.add_argument( |
| 410 | "--disable", |
| 411 | action="store_true", |
| 412 | default=False, |
| 413 | help="Mark maintenance as disabled.", |
| 414 | ) |
| 415 | p_sched.set_defaults(maint_func=_cmd_schedule) |
| 416 | |
| 417 | parser.set_defaults(func=run) |
| 418 | |
| 419 | |
| 420 | # --------------------------------------------------------------------------- |
| 421 | # Entry point |
| 422 | # --------------------------------------------------------------------------- |
| 423 | |
| 424 | |
| 425 | def run(args: argparse.Namespace) -> None: |
| 426 | root = require_repo() |
| 427 | args.maint_func(args, root) |
File History
1 commit
sha256:2fa778aba8ab0ec15295b8624c6480a573482ffc9c206a6d9546f1c41d2c2b7b
feat: supercharge muse blame + remove --porcelain everywhere
Human
patch
164 days ago