maintenance.py
python
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b
docs(mwp-master): tick all ACs green; mark MWP-7 complete w…
Sonnet 4.6
20 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`` and ``verify-objects``). ``--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 record for each task. |
| 27 | |
| 28 | ``schedule [--period-hours N] [--enable] [--disable] [--json]`` |
| 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``). ``--enable`` and ``--disable`` are mutually |
| 33 | exclusive. |
| 34 | |
| 35 | State |
| 36 | ----- |
| 37 | All state is stored in ``.muse/maintenance.json``:: |
| 38 | |
| 39 | { |
| 40 | "enabled": true, |
| 41 | "period_hours": 24, |
| 42 | "tasks": ["gc", "verify-objects"], |
| 43 | "last_run": { |
| 44 | "gc": { |
| 45 | "timestamp": "2026-04-14T12:00:00+00:00", |
| 46 | "status": "ok", |
| 47 | "duration_ms": 42.3 |
| 48 | }, |
| 49 | "verify-objects": { |
| 50 | "timestamp": "2026-04-14T12:00:00+00:00", |
| 51 | "status": "ok", |
| 52 | "duration_ms": 1200.5 |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | JSON output (``run --json``):: |
| 58 | |
| 59 | { |
| 60 | "status": "ok", |
| 61 | "error": "", |
| 62 | "tasks_run": ["gc"], |
| 63 | "results": {"gc": {...}}, |
| 64 | "dry_run": false, |
| 65 | "duration_ms": 12.4, |
| 66 | "exit_code": 0 |
| 67 | } |
| 68 | |
| 69 | JSON output (``status --json``):: |
| 70 | |
| 71 | { |
| 72 | "status": "ok", |
| 73 | "error": "", |
| 74 | "enabled": true, |
| 75 | "period_hours": 24, |
| 76 | "tasks": ["gc", "verify-objects"], |
| 77 | "last_run": { |
| 78 | "gc": {"timestamp": "2026-...", "status": "ok", "duration_ms": 42.3} |
| 79 | }, |
| 80 | "exit_code": 0 |
| 81 | } |
| 82 | |
| 83 | JSON output (``schedule --json``):: |
| 84 | |
| 85 | { |
| 86 | "status": "ok", |
| 87 | "error": "", |
| 88 | "enabled": true, |
| 89 | "period_hours": 24, |
| 90 | "exit_code": 0 |
| 91 | } |
| 92 | |
| 93 | JSON error schema (exit non-zero):: |
| 94 | |
| 95 | { |
| 96 | "status": "error", |
| 97 | "error": "<human-readable message>", |
| 98 | "exit_code": <int> |
| 99 | } |
| 100 | |
| 101 | When ``--json`` is active all errors go to stdout as JSON — no prose on |
| 102 | stderr. Agents should parse stdout and check ``status``. |
| 103 | |
| 104 | Exit codes:: |
| 105 | |
| 106 | 0 — all tasks completed (even if verify-objects found issues) |
| 107 | 1 — a task argument was invalid |
| 108 | 2 — usage error |
| 109 | """ |
| 110 | |
| 111 | import argparse |
| 112 | import json as _json |
| 113 | import logging |
| 114 | import sys |
| 115 | from typing import TypedDict |
| 116 | |
| 117 | from muse.core.types import load_json_file, long_id, now_utc_iso |
| 118 | from muse.core.paths import maintenance_json_path as _maintenance_json_path |
| 119 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 120 | from muse.core.errors import ExitCode |
| 121 | from muse.core.gc import _DEFAULT_GRACE_PERIOD_SECONDS, run_gc, prune_stale_remote_refs |
| 122 | from muse.core.object_store import iter_stored_objects |
| 123 | from muse.core.repo import require_repo |
| 124 | from muse.core.timing import start_timer |
| 125 | |
| 126 | logger = logging.getLogger(__name__) |
| 127 | |
| 128 | # --------------------------------------------------------------------------- |
| 129 | # Constants |
| 130 | # --------------------------------------------------------------------------- |
| 131 | |
| 132 | _ALL_TASKS: list[str] = ["gc", "verify-objects"] |
| 133 | _DEFAULT_TASKS: list[str] = ["gc", "verify-objects"] |
| 134 | _DEFAULT_PERIOD_HOURS: int = 24 |
| 135 | _CONFIG_FILE = "maintenance.json" |
| 136 | |
| 137 | # --------------------------------------------------------------------------- |
| 138 | # Wire-format TypedDicts |
| 139 | # --------------------------------------------------------------------------- |
| 140 | |
| 141 | class _GcResultDict(TypedDict): |
| 142 | collected_count: int |
| 143 | collected_bytes: int |
| 144 | reachable_count: int |
| 145 | commits_collected: int |
| 146 | snapshots_collected: int |
| 147 | stale_remote_refs_collected: int |
| 148 | stale_remote_refs_bytes: int |
| 149 | dry_run: bool |
| 150 | duration_ms: float |
| 151 | |
| 152 | class _VerifyResultDict(TypedDict): |
| 153 | checked: int |
| 154 | failed: int |
| 155 | failures: list[str] |
| 156 | duration_ms: float |
| 157 | |
| 158 | class _TaskRunRecord(TypedDict): |
| 159 | """Per-task run record stored in ``last_run`` map.""" |
| 160 | timestamp: str # ISO-8601 UTC |
| 161 | status: str # "ok" | "error" | "unknown" |
| 162 | duration_ms: float |
| 163 | |
| 164 | # last_run values are _TaskRunRecord dicts; hyphens in task names prevent |
| 165 | # expressing this as a TypedDict, so use a plain dict alias. |
| 166 | type _LastRunMap = dict[str, _TaskRunRecord] |
| 167 | |
| 168 | class _MaintenanceConfig(TypedDict, total=False): |
| 169 | enabled: bool |
| 170 | period_hours: int |
| 171 | tasks: list[str] |
| 172 | last_run: _LastRunMap |
| 173 | |
| 174 | type _TaskResultsMap = dict[str, _GcResultDict | _VerifyResultDict] |
| 175 | |
| 176 | class _MaintenanceRunJson(EnvelopeJson): |
| 177 | """Stable JSON envelope for ``maintenance run --json``.""" |
| 178 | status: str # "ok" |
| 179 | error: str # always "" on success |
| 180 | tasks_run: list[str] |
| 181 | results: _TaskResultsMap |
| 182 | dry_run: bool |
| 183 | |
| 184 | class _MaintenanceStatusJson(EnvelopeJson): |
| 185 | """Stable JSON envelope for ``maintenance status --json``.""" |
| 186 | status: str # "ok" |
| 187 | error: str # always "" on success |
| 188 | enabled: bool |
| 189 | period_hours: int |
| 190 | tasks: list[str] |
| 191 | last_run: _LastRunMap |
| 192 | |
| 193 | class _MaintenanceScheduleJson(EnvelopeJson): |
| 194 | """Stable JSON envelope for ``maintenance schedule --json``.""" |
| 195 | status: str # "ok" |
| 196 | error: str # always "" on success |
| 197 | enabled: bool |
| 198 | period_hours: int |
| 199 | |
| 200 | class _MaintenanceErrorJson(EnvelopeJson): |
| 201 | """Error payload for any ``maintenance`` subcommand in --json mode.""" |
| 202 | status: str # "error" |
| 203 | error: str |
| 204 | |
| 205 | # --------------------------------------------------------------------------- |
| 206 | # Config I/O |
| 207 | # --------------------------------------------------------------------------- |
| 208 | |
| 209 | def _config_path(root: "pathlib.Path") -> "pathlib.Path": |
| 210 | return _maintenance_json_path(root) |
| 211 | |
| 212 | def _read_config(root: "pathlib.Path") -> _MaintenanceConfig: |
| 213 | path = _config_path(root) |
| 214 | _default: _MaintenanceConfig = { |
| 215 | "enabled": True, |
| 216 | "period_hours": _DEFAULT_PERIOD_HOURS, |
| 217 | "tasks": list(_DEFAULT_TASKS), |
| 218 | "last_run": {}, |
| 219 | } |
| 220 | if not path.exists(): |
| 221 | return _default |
| 222 | raw = load_json_file(path) |
| 223 | if raw is None: |
| 224 | logger.warning( |
| 225 | "⚠️ Could not parse %s — using default maintenance config", path.name |
| 226 | ) |
| 227 | return _default |
| 228 | cfg: _MaintenanceConfig = { |
| 229 | "enabled": bool(raw.get("enabled", True)), |
| 230 | "period_hours": int(raw.get("period_hours", _DEFAULT_PERIOD_HOURS)), |
| 231 | "tasks": list(raw.get("tasks") or _DEFAULT_TASKS), |
| 232 | "last_run": raw.get("last_run") or {}, |
| 233 | } |
| 234 | return cfg |
| 235 | |
| 236 | def _write_config(root: "pathlib.Path", cfg: _MaintenanceConfig) -> None: |
| 237 | _config_path(root).write_text(_json.dumps(cfg), encoding="utf-8") |
| 238 | |
| 239 | def _now_iso() -> str: |
| 240 | return now_utc_iso() |
| 241 | |
| 242 | # --------------------------------------------------------------------------- |
| 243 | # Helpers |
| 244 | # --------------------------------------------------------------------------- |
| 245 | |
| 246 | def _emit_error(json_out: bool, msg: str, code: ExitCode, elapsed: float) -> None: |
| 247 | """Print an error and raise SystemExit. Never returns. |
| 248 | |
| 249 | In ``--json`` mode the error goes to stdout as a JSON payload so machine |
| 250 | consumers always get parseable output. In text mode it goes to stderr. |
| 251 | """ |
| 252 | if json_out: |
| 253 | print(_json.dumps(_MaintenanceErrorJson( |
| 254 | **make_envelope(elapsed, exit_code=int(code)), |
| 255 | status="error", |
| 256 | error=msg, |
| 257 | ))) |
| 258 | else: |
| 259 | print(f"❌ {msg}", file=sys.stderr) |
| 260 | raise SystemExit(code) |
| 261 | |
| 262 | # --------------------------------------------------------------------------- |
| 263 | # Task implementations |
| 264 | # --------------------------------------------------------------------------- |
| 265 | |
| 266 | def _run_gc(root: "pathlib.Path", *, dry_run: bool = False) -> _GcResultDict: |
| 267 | """Run garbage collection; return a result summary dict.""" |
| 268 | from muse.cli.config import list_remotes |
| 269 | elapsed = start_timer() |
| 270 | result = run_gc( |
| 271 | root, |
| 272 | dry_run=dry_run, |
| 273 | grace_period_seconds=0 if dry_run else _DEFAULT_GRACE_PERIOD_SECONDS, |
| 274 | full=True, |
| 275 | ) |
| 276 | configured_names = {r["name"] for r in list_remotes(root)} |
| 277 | prune_stale_remote_refs(root, configured_names, result, dry_run=dry_run) |
| 278 | return { |
| 279 | "collected_count": result.collected_count, |
| 280 | "collected_bytes": result.collected_bytes, |
| 281 | "reachable_count": result.reachable_count, |
| 282 | "commits_collected": result.commits_collected, |
| 283 | "snapshots_collected": result.snapshots_collected, |
| 284 | "stale_remote_refs_collected": result.stale_remote_refs_collected, |
| 285 | "stale_remote_refs_bytes": result.stale_remote_refs_bytes, |
| 286 | "dry_run": dry_run, |
| 287 | "duration_ms": elapsed(), |
| 288 | } |
| 289 | |
| 290 | def _run_verify_objects(root: "pathlib.Path") -> _VerifyResultDict: |
| 291 | """Rehash every object; return summary with checked/failed counts. |
| 292 | |
| 293 | All object IDs in the ``failures`` list are ``sha256:``-prefixed for |
| 294 | consistency with the rest of the Muse API. |
| 295 | """ |
| 296 | import hashlib |
| 297 | |
| 298 | elapsed = start_timer() |
| 299 | checked = 0 |
| 300 | failed = 0 |
| 301 | failures: list[str] = [] |
| 302 | |
| 303 | for oid, obj_file in sorted(iter_stored_objects(root), key=lambda t: t[0]): |
| 304 | checked += 1 |
| 305 | try: |
| 306 | h = hashlib.sha256() |
| 307 | with obj_file.open("rb") as fh: |
| 308 | for chunk in iter(lambda: fh.read(65536), b""): |
| 309 | h.update(chunk) |
| 310 | if long_id(h.hexdigest()) != oid: |
| 311 | failed += 1 |
| 312 | failures.append(oid) |
| 313 | except OSError: |
| 314 | failed += 1 |
| 315 | failures.append(oid) |
| 316 | |
| 317 | return { |
| 318 | "checked": checked, |
| 319 | "failed": failed, |
| 320 | "failures": failures[:20], # cap to avoid huge JSON |
| 321 | "duration_ms": elapsed(), |
| 322 | } |
| 323 | |
| 324 | # --------------------------------------------------------------------------- |
| 325 | # Subcommand handlers |
| 326 | # --------------------------------------------------------------------------- |
| 327 | |
| 328 | _TASK_RUNNERS = { |
| 329 | "gc": _run_gc, |
| 330 | "verify-objects": _run_verify_objects, |
| 331 | } |
| 332 | |
| 333 | def _cmd_run(args: argparse.Namespace, root: "pathlib.Path") -> None: |
| 334 | elapsed = start_timer() |
| 335 | dry_run: bool = args.dry_run |
| 336 | json_out: bool = args.json_out |
| 337 | |
| 338 | # Validate requested tasks. |
| 339 | if args.all: |
| 340 | tasks_to_run = list(_ALL_TASKS) |
| 341 | elif args.task: |
| 342 | tasks_to_run = list(args.task) |
| 343 | unknown = [t for t in tasks_to_run if t not in _TASK_RUNNERS] |
| 344 | if unknown: |
| 345 | _emit_error( |
| 346 | json_out, |
| 347 | f"Unknown task(s): {', '.join(unknown)}. Available: {', '.join(_ALL_TASKS)}", |
| 348 | ExitCode.USER_ERROR, |
| 349 | elapsed, |
| 350 | ) |
| 351 | else: |
| 352 | cfg = _read_config(root) |
| 353 | tasks_to_run = cfg.get("tasks") or list(_DEFAULT_TASKS) |
| 354 | |
| 355 | results: _TaskResultsMap = {} |
| 356 | task_records: _LastRunMap = {} |
| 357 | |
| 358 | for task in tasks_to_run: |
| 359 | runner_fn = _TASK_RUNNERS[task] |
| 360 | t0 = start_timer() |
| 361 | try: |
| 362 | if task == "gc": |
| 363 | results[task] = runner_fn(root, dry_run=dry_run) |
| 364 | else: |
| 365 | results[task] = runner_fn(root) |
| 366 | task_records[task] = _TaskRunRecord( |
| 367 | timestamp=_now_iso(), |
| 368 | status="ok", |
| 369 | duration_ms=t0(), |
| 370 | ) |
| 371 | except Exception as exc: # noqa: BLE001 |
| 372 | task_records[task] = _TaskRunRecord( |
| 373 | timestamp=_now_iso(), |
| 374 | status="error", |
| 375 | duration_ms=t0(), |
| 376 | ) |
| 377 | logger.error("maintenance: task %r failed: %s", task, exc) |
| 378 | |
| 379 | # Persist run records (skipped in dry-run). |
| 380 | if not dry_run: |
| 381 | cfg = _read_config(root) |
| 382 | if "last_run" not in cfg: |
| 383 | cfg["last_run"] = {} |
| 384 | cfg["last_run"].update(task_records) |
| 385 | _write_config(root, cfg) |
| 386 | |
| 387 | if json_out: |
| 388 | print(_json.dumps(_MaintenanceRunJson( |
| 389 | **make_envelope(elapsed), |
| 390 | status="ok", |
| 391 | error="", |
| 392 | tasks_run=tasks_to_run, |
| 393 | results=results, |
| 394 | dry_run=dry_run, |
| 395 | ))) |
| 396 | else: |
| 397 | prefix = "[dry-run] " if dry_run else "" |
| 398 | for task in tasks_to_run: |
| 399 | r = results.get(task) |
| 400 | if r is None: |
| 401 | print(f"{prefix}❌ {task}: failed (see logs)") |
| 402 | continue |
| 403 | if task == "gc": |
| 404 | action = "Would remove" if dry_run else "Removed" |
| 405 | print( |
| 406 | f"{prefix}{action} {r['collected_count']} unreachable object(s) " |
| 407 | f"({r['collected_bytes']} bytes); " |
| 408 | f"{r['reachable_count']} reachable" |
| 409 | ) |
| 410 | elif task == "verify-objects": |
| 411 | status = "✅" if r["failed"] == 0 else "❌" |
| 412 | print( |
| 413 | f"{prefix}{status} verify-objects: " |
| 414 | f"{r['checked']} checked, {r['failed']} failed" |
| 415 | ) |
| 416 | elapsed_s = elapsed() / 1000 |
| 417 | print( |
| 418 | f"{prefix}Done in {elapsed_s:.3f}s" |
| 419 | + (" (no changes written)" if dry_run else "") |
| 420 | ) |
| 421 | |
| 422 | def _cmd_status(args: argparse.Namespace, root: "pathlib.Path") -> None: |
| 423 | elapsed = start_timer() |
| 424 | cfg = _read_config(root) |
| 425 | json_out: bool = args.json_out |
| 426 | |
| 427 | enabled = cfg.get("enabled", True) |
| 428 | period_hours = cfg.get("period_hours", _DEFAULT_PERIOD_HOURS) |
| 429 | last_run: _LastRunMap = cfg.get("last_run", {}) |
| 430 | |
| 431 | if json_out: |
| 432 | print(_json.dumps(_MaintenanceStatusJson( |
| 433 | **make_envelope(elapsed), |
| 434 | status="ok", |
| 435 | error="", |
| 436 | enabled=enabled, |
| 437 | period_hours=period_hours, |
| 438 | tasks=cfg.get("tasks", list(_DEFAULT_TASKS)), |
| 439 | last_run=last_run, |
| 440 | ))) |
| 441 | return |
| 442 | |
| 443 | status_label = "enabled" if enabled else "disabled" |
| 444 | print(f"Maintenance: {status_label} (period: {period_hours}h)") |
| 445 | if not last_run: |
| 446 | print(" Never run.") |
| 447 | else: |
| 448 | for task, record in sorted(last_run.items()): |
| 449 | ts = record.get("timestamp", "") |
| 450 | st = record.get("status", "unknown") |
| 451 | icon = "✅" if st == "ok" else ("❌" if st == "error" else "?") |
| 452 | print(f" {task:<20} {icon} {ts}") |
| 453 | |
| 454 | def _cmd_schedule(args: argparse.Namespace, root: "pathlib.Path") -> None: |
| 455 | elapsed = start_timer() |
| 456 | period_hours: int | None = args.period_hours |
| 457 | enable: bool = args.enable |
| 458 | disable: bool = args.disable |
| 459 | json_out: bool = getattr(args, "json_out", False) |
| 460 | |
| 461 | if enable and disable: |
| 462 | _emit_error(json_out, "--enable and --disable are mutually exclusive", ExitCode.USER_ERROR, elapsed) |
| 463 | |
| 464 | if period_hours is not None and period_hours < 0: |
| 465 | _emit_error(json_out, "--period-hours must be ≥ 0", ExitCode.USER_ERROR, elapsed) |
| 466 | |
| 467 | cfg = _read_config(root) |
| 468 | |
| 469 | if period_hours is not None: |
| 470 | cfg["period_hours"] = period_hours |
| 471 | if enable: |
| 472 | cfg["enabled"] = True |
| 473 | if disable: |
| 474 | cfg["enabled"] = False |
| 475 | |
| 476 | _write_config(root, cfg) |
| 477 | |
| 478 | enabled_now = cfg.get("enabled", True) |
| 479 | ph_now = cfg.get("period_hours", _DEFAULT_PERIOD_HOURS) |
| 480 | |
| 481 | if json_out: |
| 482 | print(_json.dumps(_MaintenanceScheduleJson( |
| 483 | **make_envelope(elapsed), |
| 484 | status="ok", |
| 485 | error="", |
| 486 | enabled=enabled_now, |
| 487 | period_hours=ph_now, |
| 488 | ))) |
| 489 | else: |
| 490 | status = "enabled" if enabled_now else "disabled" |
| 491 | print(f"Maintenance schedule updated: {status}, period: {ph_now}h") |
| 492 | |
| 493 | # --------------------------------------------------------------------------- |
| 494 | # Registration |
| 495 | # --------------------------------------------------------------------------- |
| 496 | |
| 497 | def register( |
| 498 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 499 | ) -> None: |
| 500 | """Register the ``muse maintenance`` subcommand.""" |
| 501 | parser = subparsers.add_parser( |
| 502 | "maintenance", |
| 503 | help="Scheduled store maintenance orchestration.", |
| 504 | description=__doc__, |
| 505 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 506 | ) |
| 507 | sub = parser.add_subparsers(dest="maint_command", metavar="SUBCOMMAND") |
| 508 | sub.required = True |
| 509 | |
| 510 | # run |
| 511 | p_run = sub.add_parser( |
| 512 | "run", |
| 513 | help="Run maintenance tasks.", |
| 514 | ) |
| 515 | p_run.add_argument( |
| 516 | "--task", |
| 517 | action="append", |
| 518 | metavar="TASK", |
| 519 | choices=_ALL_TASKS, |
| 520 | dest="task", |
| 521 | help=f"Task to run (repeatable). Choices: {', '.join(_ALL_TASKS)}", |
| 522 | ) |
| 523 | p_run.add_argument( |
| 524 | "--all", "-a", |
| 525 | action="store_true", |
| 526 | default=False, |
| 527 | help="Run all maintenance tasks.", |
| 528 | ) |
| 529 | p_run.add_argument( |
| 530 | "--dry-run", "-n", |
| 531 | action="store_true", |
| 532 | default=False, |
| 533 | dest="dry_run", |
| 534 | help="Preview without making changes or updating timestamps.", |
| 535 | ) |
| 536 | p_run.add_argument( |
| 537 | "--json", "-j", |
| 538 | action="store_true", |
| 539 | default=False, |
| 540 | dest="json_out", |
| 541 | help="Emit a JSON result object.", |
| 542 | ) |
| 543 | p_run.set_defaults(maint_func=_cmd_run) |
| 544 | |
| 545 | # status |
| 546 | p_status = sub.add_parser("status", help="Show schedule and last-run info.") |
| 547 | p_status.add_argument( |
| 548 | "--json", "-j", |
| 549 | action="store_true", |
| 550 | default=False, |
| 551 | dest="json_out", |
| 552 | ) |
| 553 | p_status.set_defaults(maint_func=_cmd_status) |
| 554 | |
| 555 | # schedule |
| 556 | p_sched = sub.add_parser("schedule", help="Configure maintenance schedule.") |
| 557 | p_sched.add_argument( |
| 558 | "--period-hours", |
| 559 | type=int, |
| 560 | default=None, |
| 561 | metavar="N", |
| 562 | dest="period_hours", |
| 563 | help="Suggested interval between runs (hours).", |
| 564 | ) |
| 565 | p_sched.add_argument( |
| 566 | "--enable", |
| 567 | action="store_true", |
| 568 | default=False, |
| 569 | help="Mark maintenance as enabled.", |
| 570 | ) |
| 571 | p_sched.add_argument( |
| 572 | "--disable", |
| 573 | action="store_true", |
| 574 | default=False, |
| 575 | help="Mark maintenance as disabled.", |
| 576 | ) |
| 577 | p_sched.add_argument( |
| 578 | "--json", "-j", |
| 579 | action="store_true", |
| 580 | default=False, |
| 581 | dest="json_out", |
| 582 | help="Emit a JSON result object.", |
| 583 | ) |
| 584 | p_sched.set_defaults(maint_func=_cmd_schedule) |
| 585 | |
| 586 | parser.set_defaults(func=run) |
| 587 | |
| 588 | # --------------------------------------------------------------------------- |
| 589 | # Entry point |
| 590 | # --------------------------------------------------------------------------- |
| 591 | |
| 592 | def run(args: argparse.Namespace) -> None: |
| 593 | """Orchestrate scheduled store-maintenance tasks. |
| 594 | |
| 595 | Dispatches to one of three subcommands: ``run`` (execute tasks), ``status`` |
| 596 | (show schedule and last-run timestamps), or ``schedule`` (write schedule |
| 597 | config). All subcommands accept ``--json`` for machine-readable output. |
| 598 | |
| 599 | Agent quickstart |
| 600 | ---------------- |
| 601 | :: |
| 602 | |
| 603 | muse maintenance run --all --json |
| 604 | muse maintenance run --task gc --json |
| 605 | muse maintenance status --json |
| 606 | muse maintenance schedule --period-hours 12 --enable --json |
| 607 | |
| 608 | JSON fields (run) |
| 609 | ----------------- |
| 610 | status ``"ok"`` on success. |
| 611 | tasks_run List of task names that were executed. |
| 612 | results Map of task name → result summary dict. |
| 613 | dry_run ``true`` when ``--dry-run`` was passed (no state written). |
| 614 | |
| 615 | JSON fields (status) |
| 616 | -------------------- |
| 617 | status ``"ok"`` on success. |
| 618 | enabled Whether maintenance is enabled in the schedule config. |
| 619 | period_hours Suggested inter-run interval in hours. |
| 620 | tasks Tasks listed in the schedule config. |
| 621 | last_run Map of task name → ``{timestamp, status, duration_ms}``. |
| 622 | |
| 623 | Exit codes |
| 624 | ---------- |
| 625 | 0 Success. |
| 626 | 1 Unknown task name, invalid ``--period-hours``, or conflicting flags. |
| 627 | 2 Not inside a Muse repository. |
| 628 | """ |
| 629 | root = require_repo() |
| 630 | args.maint_func(args, root) |
File History
1 commit
sha256:057be43e401106b2b4d877e8028ae67d16a966562d7429707b71a5eaacd5027b
docs(mwp-master): tick all ACs green; mark MWP-7 complete w…
Sonnet 4.6
20 days ago