2776813df3
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in the focused modules that define them. This commit is the ONLY thing keeping the old paths alive, so external plugins have time to update. It is deliberately a single, unsquashed commit: git revert <this sha> removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does. What it adds (see COMPAT_MANIFEST.md, compat_manifest.json): - 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file - 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import, so no import cycles; facades that already had `__getattr__` get a chained one - 592 third-party/stdlib names the old modules used to expose, with their original import statements - 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them) - 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/ relay_runtime, tools/environments/modal_utils) - private names (`_x`) get no pointer: they were never API (3,792 skipped) Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves; the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
239 lines
9.6 KiB
Python
239 lines
9.6 KiB
Python
"""`hermes checkpoints` CLI subcommand.
|
|
|
|
None of these require the agent to be running. Safe to call any time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from hermes_cli.sizefmt import format_bytes as _fmt_bytes
|
|
|
|
|
|
def _fmt_age(ts: Any) -> str:
|
|
try:
|
|
age = time.time() - float(ts)
|
|
except (TypeError, ValueError):
|
|
return "—"
|
|
if age < 0:
|
|
return "now"
|
|
for bound, div, unit in ((60, 1, "s"), (3600, 60, "m"), (86400, 3600, "h")):
|
|
if age < bound:
|
|
return f"{int(age / div)}{unit} ago"
|
|
return f"{int(age / 86400)}d ago"
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
from tools.checkpoint_manager import store_status
|
|
|
|
info = store_status()
|
|
base = info["base"]
|
|
print(f"Checkpoint base: {base}")
|
|
print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}")
|
|
print(f" store/ {_fmt_bytes(info['store_size_bytes'])}")
|
|
print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}")
|
|
print(f"Projects: {info['project_count']}")
|
|
|
|
projects = sorted(info["projects"], key=lambda p: (p.get("last_touch") or 0), reverse=True)
|
|
if projects:
|
|
print()
|
|
print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE")
|
|
for p in projects[: getattr(args, "limit", None) or 20]:
|
|
wd = p.get("workdir") or "(unknown)"
|
|
if len(wd) > 60:
|
|
wd = "…" + wd[-59:]
|
|
state = "live" if p.get("exists") else "orphan"
|
|
print(f" {wd:<60} {p.get('commits', 0):>7} {_fmt_age(p.get('last_touch')):>12} {state}")
|
|
|
|
legacy = info.get("legacy_archives", [])
|
|
if legacy:
|
|
print()
|
|
print(f"Legacy archives ({len(legacy)}):")
|
|
_print_archives(sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True))
|
|
print()
|
|
print("Clear with: hermes checkpoints clear-legacy")
|
|
return 0
|
|
|
|
|
|
def _print_archives(archives) -> None:
|
|
for arch in archives:
|
|
print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
|
|
|
|
|
|
def cmd_prune(args: argparse.Namespace) -> int:
|
|
from tools.checkpoint_manager import prune_checkpoints, store_status
|
|
|
|
delete_orphans = not args.keep_orphans
|
|
|
|
# Restricts orphan deletion to exactly the identities shown in the confirmation preview
|
|
# (v2 project hashes / pre-v2 shadow repo paths). `None` = no restriction (--force: no
|
|
# preview to bind to).
|
|
orphan_allowlist: Optional[set] = None
|
|
|
|
if delete_orphans and not args.force:
|
|
info = store_status()
|
|
orphans = [p for p in info.get("projects", []) if not p.get("exists")]
|
|
pre_v2_orphans = [p for p in info.get("pre_v2_projects", []) if not p.get("exists")]
|
|
if orphans or pre_v2_orphans:
|
|
print(f"This will permanently delete {len(orphans) + len(pre_v2_orphans)} "
|
|
"orphan checkpoint project(s) whose workdir is not currently reachable:")
|
|
print()
|
|
for p in orphans:
|
|
print(f" {p.get('workdir') or '(unknown)'} ({p.get('commits', 0)} commit(s))")
|
|
for p in pre_v2_orphans:
|
|
print(f" {p.get('workdir') or '(unknown)'} (pre-v2 shadow repo)")
|
|
print()
|
|
print("A workdir can be unreachable because the project was deleted,")
|
|
print("or because an external volume / network share / VPN is down.")
|
|
print("Pass --keep-orphans to prune stale entries only.")
|
|
if not _confirm("Delete these orphan projects?"):
|
|
print("Aborted.")
|
|
return 1
|
|
# Bind deletion to exactly what was displayed/confirmed: a project that goes orphan
|
|
# only *after* the preview (workdir vanishes while waiting on input()) must not be
|
|
# swept up. Set unconditionally for every non-force run — an EMPTY preview binds an
|
|
# EMPTY allowlist, so it can never authorize orphans found by the later rescan.
|
|
orphan_allowlist = {p["hash"] for p in orphans}
|
|
orphan_allowlist.update(p["path"] for p in pre_v2_orphans)
|
|
|
|
print("Pruning checkpoint store…")
|
|
print(f" retention_days: {args.retention_days}")
|
|
print(f" delete_orphans: {delete_orphans}")
|
|
print(f" max_total_size_mb: {args.max_size_mb}")
|
|
print()
|
|
|
|
result = prune_checkpoints(
|
|
retention_days=args.retention_days,
|
|
delete_orphans=delete_orphans,
|
|
max_total_size_mb=args.max_size_mb,
|
|
orphan_allowlist=orphan_allowlist)
|
|
print(f"Scanned: {result['scanned']}")
|
|
print(f"Deleted orphan: {result['deleted_orphan']}")
|
|
print(f"Deleted stale: {result['deleted_stale']}")
|
|
print(f"Errors: {result['errors']}")
|
|
print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}")
|
|
return 0
|
|
|
|
|
|
def _confirm(prompt: str) -> bool:
|
|
try:
|
|
return input(f"{prompt} [y/N]: ").strip().lower() in {"y", "yes"}
|
|
except (EOFError, KeyboardInterrupt):
|
|
print()
|
|
return False
|
|
|
|
|
|
def _confirmed(args: argparse.Namespace, prompt: str) -> bool:
|
|
"""``--force`` or an interactive yes; prints ``Aborted.`` otherwise."""
|
|
if args.force or _confirm(prompt):
|
|
return True
|
|
print("Aborted.")
|
|
return False
|
|
|
|
|
|
def cmd_clear(args: argparse.Namespace) -> int:
|
|
from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status
|
|
|
|
info = store_status()
|
|
if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists():
|
|
print("Nothing to clear — checkpoint base does not exist.")
|
|
return 0
|
|
|
|
print(f"This will delete the ENTIRE checkpoint base at {info['base']}")
|
|
print(f" size: {_fmt_bytes(info['total_size_bytes'])}")
|
|
print(f" projects: {info['project_count']}")
|
|
print(f" legacy dirs: {len(info.get('legacy_archives', []))}")
|
|
print()
|
|
print("All /rollback history for every working directory will be lost.")
|
|
if not _confirmed(args, "Proceed?"):
|
|
return 1
|
|
|
|
result = clear_all()
|
|
if result["deleted"]:
|
|
print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.")
|
|
return 0
|
|
print("Could not clear checkpoint base (see logs).")
|
|
return 2
|
|
|
|
|
|
def cmd_clear_legacy(args: argparse.Namespace) -> int:
|
|
from tools.checkpoint_manager import clear_legacy, store_status
|
|
|
|
info = store_status()
|
|
legacy = info.get("legacy_archives", [])
|
|
if not legacy:
|
|
print("No legacy archives to clear.")
|
|
return 0
|
|
|
|
total = sum(a.get("size_bytes", 0) for a in legacy)
|
|
print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:")
|
|
_print_archives(legacy)
|
|
print()
|
|
print("Legacy archives hold pre-v2 per-project shadow repos, moved aside")
|
|
print("during the single-store migration. Delete when you're confident")
|
|
print("you don't need the old /rollback history.")
|
|
if not _confirmed(args, "Delete all legacy archives?"):
|
|
return 1
|
|
|
|
result = clear_legacy()
|
|
print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.")
|
|
return 0
|
|
|
|
|
|
def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
"""Wire subcommands onto the ``hermes checkpoints`` parser."""
|
|
parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status
|
|
subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND")
|
|
|
|
p_status = subs.add_parser("status", help="Show total size, project count, and per-project breakdown")
|
|
p_status.add_argument("--limit", type=int, default=20, help="Max projects to list (default 20)")
|
|
p_status.set_defaults(func=cmd_status)
|
|
|
|
p_list = subs.add_parser("list", help="Alias for 'status'")
|
|
p_list.add_argument("--limit", type=int, default=20)
|
|
p_list.set_defaults(func=cmd_status)
|
|
|
|
p_prune = subs.add_parser("prune", help="Delete orphan/stale checkpoints and GC the store")
|
|
p_prune.add_argument("--retention-days", type=int, default=7,
|
|
help="Drop projects whose last_touch is older than N days (default 7)")
|
|
p_prune.add_argument("--max-size-mb", type=int, default=500,
|
|
help="After orphan/stale prune, drop oldest commits "
|
|
"per project until total size <= this (default 500)")
|
|
p_prune.add_argument("--keep-orphans", action="store_true",
|
|
help="Skip deleting projects whose workdir no longer exists")
|
|
p_prune.add_argument("-f", "--force", action="store_true",
|
|
help="Skip the orphan-deletion confirmation prompt")
|
|
p_prune.set_defaults(func=cmd_prune)
|
|
|
|
for name, help_text, func in (
|
|
("clear", "Delete the entire checkpoint base (all /rollback history)", cmd_clear),
|
|
("clear-legacy", "Delete only the legacy-<ts>/ archives from v1 migration", cmd_clear_legacy),
|
|
):
|
|
p_clear = subs.add_parser(name, help=help_text)
|
|
p_clear.add_argument("-f", "--force", action="store_true", help="Skip confirmation prompt")
|
|
p_clear.set_defaults(func=func)
|
|
|
|
|
|
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
|
|
# Names external plugins imported from this module before the Sep 2026 decomposition.
|
|
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
|
|
# The whole block is removed by reverting the commit that added it.
|
|
from datetime import datetime # noqa: F401,E402
|
|
|
|
|
|
_PLUGIN_COMPAT_LAZY = {
|
|
'cmd_list': ('hermes_cli.plugins_cmd', 'cmd_list'),
|
|
}
|
|
|
|
|
|
def __getattr__(name): # PEP 562 — lazy so no import cycles
|
|
target = _PLUGIN_COMPAT_LAZY.get(name)
|
|
if target is None:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
import importlib
|
|
return getattr(importlib.import_module(target[0]), target[1])
|
|
# ---- END PLUGIN-COMPAT ----
|