gabriel / muse public
maintenance.py python
630 lines 19.9 KB
Raw
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