File size: 9,962 Bytes
4df9a41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | """`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
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----
|