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