Skip to content

Commit 0ab2423

Browse files
authored
feat(update): auto-backup HERMES_HOME before hermes update (NousResearch#16539)
Every 'hermes update' now runs a full backup of ~/.hermes/ first, so users can always roll back to the exact state they had before the update if anything goes wrong (corrupted sessions.db, broken skills, config migrations that don't round-trip, etc.). Changes: - hermes_cli/backup.py: new create_pre_update_backup() helper. Writes to <HERMES_HOME>/backups/pre-update-<stamp>.zip using the same exclusion rules and SQLite safe-copy as 'hermes backup'. Auto-rotates (keep last N, pre-update-*.zip only — hand-dropped zips in backups/ are untouched). Adds 'backups' to _EXCLUDED_DIRS so subsequent backups don't nest prior ones. - hermes_cli/main.py: _run_pre_update_backup() wired into _cmd_update_impl before any git operation. Prints save path, restore command, and how to disable. Swallows failures so a broken backup never blocks the update itself. New --no-backup flag on 'hermes update' for one-off override. - hermes_cli/config.py: new 'updates' section in DEFAULT_CONFIG with pre_update_backup (default true) and backup_keep (default 5). Auto-surfaces in the dashboard config UI. - tests/hermes_cli/test_backup.py: +11 tests covering backup location, content parity with 'hermes backup', no-recursion, rotation, manual file preservation, config gate, --no-backup flag, flag-wins-over-config.
1 parent c78d24c commit 0ab2423

4 files changed

Lines changed: 429 additions & 0 deletions

File tree

hermes_cli/backup.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"__pycache__", # bytecode caches — regenerated on import
3737
".git", # nested git dirs (profiles shouldn't have these, but safety)
3838
"node_modules", # js deps if website/ somehow leaks in
39+
"backups", # prior auto-backups — don't nest backups exponentially
3940
}
4041

4142
# File-name suffixes to skip
@@ -683,3 +684,138 @@ def run_quick_backup(args) -> None:
683684
print(f" Restore with: /snapshot restore {snap_id}")
684685
else:
685686
print("No state files found to snapshot.")
687+
688+
689+
# ---------------------------------------------------------------------------
690+
# Pre-update auto-backup
691+
# ---------------------------------------------------------------------------
692+
693+
_PRE_UPDATE_BACKUPS_DIR = "backups"
694+
_PRE_UPDATE_PREFIX = "pre-update-"
695+
_PRE_UPDATE_DEFAULT_KEEP = 5
696+
697+
698+
def _pre_update_backup_dir(hermes_home: Optional[Path] = None) -> Path:
699+
home = hermes_home or get_hermes_home()
700+
return home / _PRE_UPDATE_BACKUPS_DIR
701+
702+
703+
def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int:
704+
"""Remove oldest pre-update backups beyond the keep limit.
705+
706+
Returns the number of files deleted. Only touches files matching
707+
``pre-update-*.zip`` so hand-made zips dropped in the same directory
708+
are never touched.
709+
"""
710+
if keep < 0:
711+
keep = 0
712+
if not backup_dir.exists():
713+
return 0
714+
715+
backups = sorted(
716+
(p for p in backup_dir.iterdir()
717+
if p.is_file() and p.name.startswith(_PRE_UPDATE_PREFIX) and p.suffix.lower() == ".zip"),
718+
key=lambda p: p.name,
719+
reverse=True,
720+
)
721+
722+
deleted = 0
723+
for p in backups[keep:]:
724+
try:
725+
p.unlink()
726+
deleted += 1
727+
except OSError as exc:
728+
logger.warning("Failed to prune backup %s: %s", p.name, exc)
729+
730+
return deleted
731+
732+
733+
def create_pre_update_backup(
734+
hermes_home: Optional[Path] = None,
735+
keep: int = _PRE_UPDATE_DEFAULT_KEEP,
736+
) -> Optional[Path]:
737+
"""Create a full zip backup of HERMES_HOME under ``backups/``.
738+
739+
Mirrors :func:`run_backup` (same exclusion rules, same SQLite safe-copy)
740+
but writes to ``<HERMES_HOME>/backups/pre-update-<timestamp>.zip`` and
741+
auto-prunes old pre-update backups.
742+
743+
Returns the path to the created zip, or ``None`` if no files were
744+
found or the backup could not be created. Never raises — the caller
745+
(``hermes update``) should continue even if the backup fails.
746+
"""
747+
hermes_root = hermes_home or get_default_hermes_root()
748+
if not hermes_root.is_dir():
749+
return None
750+
751+
backup_dir = _pre_update_backup_dir(hermes_root)
752+
try:
753+
backup_dir.mkdir(parents=True, exist_ok=True)
754+
except OSError as exc:
755+
logger.warning("Could not create pre-update backup dir %s: %s", backup_dir, exc)
756+
return None
757+
758+
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
759+
out_path = backup_dir / f"{_PRE_UPDATE_PREFIX}{stamp}.zip"
760+
761+
# Collect files (same logic as run_backup, minus the chatty progress prints)
762+
files_to_add: list[tuple[Path, Path]] = []
763+
try:
764+
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
765+
dp = Path(dirpath)
766+
# Prune excluded directories in-place so os.walk doesn't descend
767+
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
768+
769+
for fname in filenames:
770+
fpath = dp / fname
771+
try:
772+
rel = fpath.relative_to(hermes_root)
773+
except ValueError:
774+
continue
775+
776+
if _should_exclude(rel):
777+
continue
778+
779+
# Skip the output zip itself if it already exists
780+
try:
781+
if fpath.resolve() == out_path.resolve():
782+
continue
783+
except (OSError, ValueError):
784+
pass
785+
786+
files_to_add.append((fpath, rel))
787+
except OSError as exc:
788+
logger.warning("Pre-update backup: walk failed: %s", exc)
789+
return None
790+
791+
if not files_to_add:
792+
return None
793+
794+
try:
795+
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
796+
for abs_path, rel_path in files_to_add:
797+
try:
798+
if abs_path.suffix == ".db":
799+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
800+
tmp_db = Path(tmp.name)
801+
try:
802+
if _safe_copy_db(abs_path, tmp_db):
803+
zf.write(tmp_db, arcname=str(rel_path))
804+
finally:
805+
tmp_db.unlink(missing_ok=True)
806+
else:
807+
zf.write(abs_path, arcname=str(rel_path))
808+
except (PermissionError, OSError, ValueError) as exc:
809+
logger.debug("Skipping %s in pre-update backup: %s", rel_path, exc)
810+
continue
811+
except OSError as exc:
812+
logger.warning("Pre-update backup: zip write failed: %s", exc)
813+
# Best-effort cleanup of partial file
814+
try:
815+
out_path.unlink(missing_ok=True)
816+
except OSError:
817+
pass
818+
return None
819+
820+
_prune_pre_update_backups(backup_dir, keep=keep)
821+
return out_path

hermes_cli/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,19 @@ def _ensure_hermes_home_managed(home: Path):
10371037
"seen": {},
10381038
},
10391039

1040+
# ``hermes update`` behaviour.
1041+
"updates": {
1042+
# Run a full ``hermes backup``-style zip of HERMES_HOME before every
1043+
# ``hermes update``. Backups land in ``<HERMES_HOME>/backups/`` and
1044+
# can be restored with ``hermes import <path>``. Set to false to
1045+
# skip the backup entirely; use the ``--no-backup`` flag on a single
1046+
# update invocation to override just that run.
1047+
"pre_update_backup": True,
1048+
# How many pre-update backup zips to retain. Older ones are pruned
1049+
# automatically after each successful backup.
1050+
"backup_keep": 5,
1051+
},
1052+
10401053
# Config schema version - bump this when adding new required fields
10411054
"_config_version": 22,
10421055
}

hermes_cli/main.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6142,6 +6142,90 @@ def _ensure_fhs_path_guard() -> None:
61426142
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
61436143

61446144

6145+
def _run_pre_update_backup(args) -> None:
6146+
"""Create a full zip backup of HERMES_HOME before running the update.
6147+
6148+
Gated on ``updates.pre_update_backup`` in config (default true). The
6149+
``--no-backup`` flag on ``hermes update`` overrides it for one run.
6150+
Never raises — a backup failure should not block the update itself.
6151+
"""
6152+
# CLI flag wins over config
6153+
if getattr(args, "no_backup", False):
6154+
print("◆ Pre-update backup: skipped (--no-backup)")
6155+
print()
6156+
return
6157+
6158+
try:
6159+
from hermes_cli.config import load_config
6160+
cfg = load_config()
6161+
except Exception as exc:
6162+
logging.getLogger(__name__).debug("Could not load config for pre-update backup: %s", exc)
6163+
cfg = {}
6164+
6165+
updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {}
6166+
enabled = updates_cfg.get("pre_update_backup", True)
6167+
keep = updates_cfg.get("backup_keep", 5)
6168+
6169+
if not enabled:
6170+
print("◆ Pre-update backup: disabled (updates.pre_update_backup=false in config.yaml)")
6171+
print()
6172+
return
6173+
6174+
try:
6175+
from hermes_cli.backup import create_pre_update_backup
6176+
except Exception as exc:
6177+
print(f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update.")
6178+
print()
6179+
return
6180+
6181+
print("◆ Creating pre-update backup...")
6182+
t0 = _time.monotonic()
6183+
try:
6184+
out_path = create_pre_update_backup(keep=int(keep))
6185+
except Exception as exc: # defensive — helper already swallows, but just in case
6186+
print(f" ⚠ Backup failed: {exc}")
6187+
print(" Continuing with update.")
6188+
print()
6189+
return
6190+
6191+
elapsed = _time.monotonic() - t0
6192+
6193+
if out_path is None:
6194+
print(" ⚠ Backup skipped (no files found or write failed); continuing update.")
6195+
print()
6196+
return
6197+
6198+
try:
6199+
size_bytes = out_path.stat().st_size
6200+
except OSError:
6201+
size_bytes = 0
6202+
6203+
# Human-readable size
6204+
size_str = f"{size_bytes} B"
6205+
for unit in ("KB", "MB", "GB"):
6206+
if size_bytes < 1024:
6207+
break
6208+
size_bytes /= 1024
6209+
size_str = f"{size_bytes:.1f} {unit}"
6210+
6211+
# Render path using display_hermes_home so the user sees ~/.hermes/...
6212+
try:
6213+
from hermes_constants import get_hermes_home, display_hermes_home
6214+
home = get_hermes_home()
6215+
try:
6216+
display_path = f"{display_hermes_home()}/{out_path.relative_to(home)}"
6217+
except ValueError:
6218+
display_path = str(out_path)
6219+
except Exception:
6220+
display_path = str(out_path)
6221+
6222+
print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)")
6223+
print(f" Restore: hermes import {out_path}")
6224+
print(f" Disable: set updates.pre_update_backup: false in config.yaml")
6225+
print(f" (or pass --no-backup on a single update)")
6226+
print()
6227+
6228+
61456229
def cmd_update(args):
61466230
"""Update Hermes Agent to the latest version.
61476231
@@ -6184,6 +6268,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
61846268
print("⚕ Updating Hermes Agent...")
61856269
print()
61866270

6271+
# Pre-update backup — runs before any git/file mutation so users can
6272+
# always roll back to the exact state they had before this update.
6273+
_run_pre_update_backup(args)
6274+
61876275
# Try git-based update first, fall back to ZIP download on Windows
61886276
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
61896277
use_zip_update = False
@@ -9577,6 +9665,12 @@ def cmd_claw(args):
95779665
default=False,
95789666
help="Check whether an update is available without installing anything",
95799667
)
9668+
update_parser.add_argument(
9669+
"--no-backup",
9670+
action="store_true",
9671+
default=False,
9672+
help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)",
9673+
)
95809674
update_parser.set_defaults(func=cmd_update)
95819675

95829676
# =========================================================================

0 commit comments

Comments
 (0)