diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 737bea1509a2..787916436b11 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -794,18 +794,51 @@ def create_quick_snapshot( label: Optional[str] = None, hermes_home: Optional[Path] = None, keep: Optional[int] = None, + max_file_size: Optional[int] = None, ) -> Optional[str]: """Create a quick state snapshot of critical files. Copies STATE_FILES to a timestamped directory under state-snapshots/. Auto-prunes old snapshots beyond the keep limit. + Args: + max_file_size: When set, individual files larger than this many bytes + are skipped (with a printed warning) instead of copied. Used by + the pre-update safety snapshot so a multi-GB ``state.db`` can + never stall ``hermes update`` or silently eat disk — the small + pairing/cron/config files the snapshot exists to protect are + always captured. ``None`` (default) copies everything, which + preserves manual ``/snapshot`` and ``hermes backup --quick`` + behavior. + Returns: Snapshot ID (timestamp-based), or None if no files found. """ home = hermes_home or get_hermes_home() root = _quick_snapshot_root(home) + def _too_large(path: Path, rel_name: str) -> bool: + """True (and warn) when ``path`` exceeds the max_file_size cap.""" + if max_file_size is None: + return False + try: + size = path.stat().st_size + except OSError: + return False + if size <= max_file_size: + return False + print( + f" ⚠ Snapshot: skipping {rel_name} " + f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)" + ) + logger.warning( + "Quick snapshot skipped %s: %d bytes exceeds %d byte limit", + rel_name, + size, + max_file_size, + ) + return True + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") snap_id = f"{ts}-{label}" if label else ts snap_dir = root / snap_id @@ -831,6 +864,8 @@ def create_quick_snapshot( # the board databases + their metadata to restore a board. if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/": continue + if _too_large(sub, sub_rel): + continue dst = snap_dir / sub_rel dst.parent.mkdir(parents=True, exist_ok=True) try: @@ -850,6 +885,9 @@ def create_quick_snapshot( if not src.is_file(): continue + if _too_large(src, rel): + continue + dst = snap_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) @@ -1239,7 +1277,7 @@ def _prune_pre_update_backups(backup_dir: Path, keep: int) -> int: than no backup at all (and the wrapper in ``main.py`` would still print a misleading ``Saved: `` line for a file that no longer exists). Operators who genuinely don't want a backup should set - ``updates.pre_update_backup: false`` in config — that gates creation. + ``updates.pre_update_backup: off`` in config — that gates creation. """ keep = max(keep, 1) if not backup_dir.exists(): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index dc0ce9721cb4..bf825664ea16 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3110,21 +3110,29 @@ DEFAULT_CONFIG = { # ``hermes update`` behaviour. "updates": { - # Run a full ``hermes backup``-style zip of HERMES_HOME before every - # ``hermes update``. Backups land in ``/backups/`` and - # can be restored with ``hermes import ``. Off by default: - # zipping a large HERMES_HOME (sessions DB, caches, skills) can add - # minutes to every update. The #48200 incident — a ``hermes update - # --yes`` run that computed a wrong path and silently wiped the - # user's ``.env``, ``MEMORY.md``, ``kanban.db``, custom skills, and - # scripts — is the reason this knob exists; enable it (here, or via - # ``--backup`` for a single run) if you want that safety net. - "pre_update_backup": False, - # How many pre-update backup zips to retain. Older ones are pruned - # automatically after each successful backup. Values below 1 are - # floored to 1 — the backup just created is always preserved. To - # disable backups entirely, set ``pre_update_backup: false`` above - # rather than ``backup_keep: 0``. + # Pre-update safety backup — ONE consolidated mechanism, three modes: + # + # quick (default) — snapshot critical small state files (pairing + # JSONs, cron jobs, config.yaml, .env, auth.json, per-profile + # DBs) into /state-snapshots/ before the update. + # Files over 1 GiB (e.g. a bloated state.db) are skipped with a + # warning so the snapshot stays fast. Restore via ``/snapshot``. + # This is the #15733 (lost pairing data) / #34600 (emptied cron + # jobs) safety net. + # full — the quick snapshot PLUS a full ``hermes backup``-style zip + # of HERMES_HOME into /backups/, restorable with + # ``hermes import``. Can add minutes on large homes. This is the + # #48200 (wrong-path wipe) safety net. ``--backup`` forces this + # for a single run. + # off — no pre-update backup of any kind. ``--no-backup`` forces + # this for a single run. + # + # Legacy boolean values are honored: true -> full, false -> off. + "pre_update_backup": "quick", + # How many full pre-update backup zips to retain (mode ``full``). + # Older ones are pruned automatically after each successful backup. + # Values below 1 are floored to 1 — the backup just created is + # always preserved. The quick snapshot always keeps exactly 1. "backup_keep": 5, # What `hermes update` does with uncommitted local changes to the # source tree when it runs NON-interactively — i.e. triggered from diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4ff7ab0900c0..2a29443d1784 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8908,23 +8908,30 @@ def _ensure_fhs_path_guard() -> None: print(" (reload your shell or run 'source ~/.bashrc' to pick it up)") -def _run_pre_update_backup(args) -> None: - """Create a full zip backup of HERMES_HOME before running the update. +_PRE_UPDATE_SNAPSHOT_KEEP = 1 - Gated on ``updates.pre_update_backup`` in config (default false). Off - by default because the zip can add minutes to every update on large - HERMES_HOME directories. The ``--backup`` flag on ``hermes update`` - opts in for a single run; ``--no-backup`` forces it off when config - has it enabled. Never raises — a backup failure should not block the - update itself. +# Per-file size cap for the pre-update quick snapshot. Anything larger is +# skipped with a warning: the snapshot exists to protect small, hard-to- +# regenerate state (pairing JSONs, cron jobs, config, auth) — not to copy a +# multi-GB state.db on every update (observed: a 24 GB state.db added ~60s +# of wall time and silently ate 24 GB of disk per update). +_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE = 1 << 30 # 1 GiB + + +def _resolve_pre_update_backup_mode(args) -> str: + """Resolve the pre-update backup mode: ``"off"``, ``"quick"``, or ``"full"``. + + CLI flags win over config; ``--no-backup`` beats ``--backup`` when both + are set. Config accepts the mode strings plus legacy booleans: + ``true`` → ``full`` (the old zip behavior), ``false`` → ``off`` + (an explicit opt-out now disables the quick snapshot too — previously + it ran unconditionally, ignoring the user's setting). A missing key + defaults to ``quick``. """ - # CLI flags win over config. --no-backup beats --backup if both are set. if getattr(args, "no_backup", False): - print("◆ Pre-update backup: skipped (--no-backup)") - print() - return - - force_backup = bool(getattr(args, "backup", False)) + return "off" + if getattr(args, "backup", False): + return "full" try: from hermes_cli.config import load_config @@ -8937,19 +8944,76 @@ def _run_pre_update_backup(args) -> None: cfg = {} updates_cfg = cfg.get("updates", {}) if isinstance(cfg, dict) else {} - # The default config ships with ``pre_update_backup: false`` (see - # ``hermes_cli/config.py``). Fall back to false if the key is missing - # so the default behaviour matches the shipped config: zipping a large - # HERMES_HOME can add minutes to every update. Users who want the - # #48200 safety net opt in via the config knob or ``--backup``. - enabled = updates_cfg.get("pre_update_backup", False) - keep = updates_cfg.get("backup_keep", 5) + raw = updates_cfg.get("pre_update_backup", "quick") - if not enabled and not force_backup: - # Silent by default — the backup is off, most users don't need to - # hear about it on every update. They can opt in via --backup - # or by flipping the config knob. - return + if raw is True: + return "full" + if raw is False: + return "off" + mode = str(raw).strip().lower() + if mode in ("off", "false", "none", "disabled"): + return "off" + if mode in ("full", "zip", "true"): + return "full" + if mode == "quick": + return "quick" + logging.getLogger(__name__).warning( + "Unknown updates.pre_update_backup value %r — using 'quick'", raw + ) + return "quick" + + +def _run_pre_update_backup(args) -> Optional[str]: + """Run the pre-update safety backup and return the quick-snapshot id. + + Single consolidated mechanism gated on ``updates.pre_update_backup``: + + - ``off`` — nothing runs. Explicit user opt-out is honored fully. + - ``quick`` (default) — a state snapshot of critical small files + (pairing JSONs, cron jobs, config, auth; see ``_QUICK_STATE_FILES``) + under ``state-snapshots/``. Files over 1 GiB are skipped with a + warning so a bloated state.db can never stall the update + (issues #15733, #34600 are the reason this safety net exists). + - ``full`` — the quick snapshot PLUS a full zip of HERMES_HOME under + ``backups/`` (restorable via ``hermes import``; the #48200 wrong-path + wipe is the reason this level exists). + + ``--backup`` forces ``full`` for one run; ``--no-backup`` forces ``off``. + Never raises — a backup failure should not block the update itself. + + Returns the quick-snapshot id (used by the post-update cron-jobs + restore safety net), or ``None`` when mode is ``off`` or the snapshot + failed. + """ + mode = _resolve_pre_update_backup_mode(args) + + if mode == "off": + if getattr(args, "no_backup", False): + print("◆ Pre-update backup: skipped (--no-backup)") + print() + # Config-level off is silent — the user opted out; don't spam them + # on every update. + return None + + snapshot_id = None + try: + from hermes_cli.backup import create_quick_snapshot + + snapshot_id = create_quick_snapshot( + label="pre-update", + keep=_PRE_UPDATE_SNAPSHOT_KEEP, + max_file_size=_PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE, + ) + if snapshot_id: + print(f"◆ Pre-update snapshot: {snapshot_id}") + except Exception as exc: + # Never let a snapshot failure block an update. + logging.getLogger(__name__).debug("Pre-update snapshot failed: %s", exc) + + if mode != "full": + if snapshot_id: + print() + return snapshot_id try: from hermes_cli.backup import create_pre_update_backup @@ -8958,24 +9022,31 @@ def _run_pre_update_backup(args) -> None: f"⚠ Pre-update backup: could not load backup module ({exc}); continuing update." ) print() - return + return snapshot_id + + try: + from hermes_cli.config import load_config + + _keep = (load_config() or {}).get("updates", {}).get("backup_keep", 5) + except Exception: + _keep = 5 print("◆ Creating pre-update backup...") t0 = _time.monotonic() try: - out_path = create_pre_update_backup(keep=int(keep)) + out_path = create_pre_update_backup(keep=int(_keep)) except Exception as exc: # defensive — helper already swallows, but just in case print(f" ⚠ Backup failed: {exc}") print(" Continuing with update.") print() - return + return snapshot_id elapsed = _time.monotonic() - t0 if out_path is None: print(" ⚠ Backup skipped (no files found or write failed); continuing update.") print() - return + return snapshot_id try: size_bytes = out_path.stat().st_size @@ -9004,9 +9075,9 @@ def _run_pre_update_backup(args) -> None: print(f" Saved: {display_path} ({size_str}, {elapsed:.1f}s)") print(f" Restore: hermes import {out_path}") - print(" Disable: omit --backup (backups are off by default)") - print(" set updates.pre_update_backup: false in config.yaml") + print(" Disable: set updates.pre_update_backup: quick (or off) in config.yaml") print() + return snapshot_id def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool: @@ -9711,7 +9782,9 @@ def _cmd_update_impl(args, gateway_mode: bool): # Pre-update backup — runs before any git/file mutation so users can # always roll back to the exact state they had before this update. - _run_pre_update_backup(args) + # Returns the quick-snapshot id (or None when disabled/failed); the + # post-update cron-jobs safety net uses it to detect job loss. + pre_update_snapshot_id = _run_pre_update_backup(args) _windows_gateway_resume = _pause_windows_gateways_for_update() if _windows_gateway_resume: @@ -9996,23 +10069,6 @@ def _cmd_update_impl(args, gateway_mode: bool): print(f"→ Found {commit_count} new commit(s)") - # Snapshot critical state (state.db, config, pairing JSONs, etc.) - # before pulling so a user can recover if something goes wrong. - # Issue #15733 reported missing pairing data after an update; even - # though `git pull` can't touch $HERMES_HOME, this is cheap - # belt-and-suspenders insurance and gives the user something to - # restore from via `/snapshot list` / `/snapshot restore `. - pre_update_snapshot_id = None - try: - from hermes_cli.backup import create_quick_snapshot - - pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1) - if pre_update_snapshot_id: - print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}") - except Exception as exc: - # Never let a snapshot failure block an update. - logger.debug("Pre-update snapshot failed: %s", exc) - print("→ Pulling updates...") update_succeeded = False # Capture the pre-pull SHA so we can auto-roll-back if the new code diff --git a/hermes_cli/subcommands/update.py b/hermes_cli/subcommands/update.py index bbd5e43e0464..f612001fa0ee 100644 --- a/hermes_cli/subcommands/update.py +++ b/hermes_cli/subcommands/update.py @@ -35,13 +35,13 @@ def build_update_parser(subparsers, *, cmd_update: Callable) -> None: "--no-backup", action="store_true", default=False, - help="Skip the pre-update backup for this run (overrides updates.pre_update_backup)", + help="Skip ALL pre-update backups for this run (both the quick state snapshot and the full zip; overrides updates.pre_update_backup)", ) update_parser.add_argument( "--backup", action="store_true", default=False, - help="Force a pre-update backup for this run (off by default; overrides updates.pre_update_backup=false)", + help="Force a FULL pre-update backup (quick state snapshot + HERMES_HOME zip) for this run, regardless of updates.pre_update_backup", ) update_parser.add_argument( "--yes", diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 17832746ca3a..4f30d50c7500 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1449,6 +1449,40 @@ class TestQuickSnapshot: empty.mkdir() assert create_quick_snapshot(hermes_home=empty) is None + def test_max_file_size_skips_oversized_file(self, hermes_home, capsys): + """Files above the cap are skipped with a warning; small files + (the pairing/cron data the snapshot exists for) still land.""" + from hermes_cli.backup import create_quick_snapshot + # state.db in the fixture is a few KB — cap below it + cap = 1024 + snap_id = create_quick_snapshot( + hermes_home=hermes_home, max_file_size=cap + ) + assert snap_id is not None + snap_dir = hermes_home / "state-snapshots" / snap_id + assert not (snap_dir / "state.db").exists() + # Small files still captured + assert (snap_dir / "cron" / "jobs.json").exists() + with open(snap_dir / "manifest.json") as f: + meta = json.load(f) + assert "state.db" not in meta["files"] + out = capsys.readouterr().out + assert "skipping state.db" in out + assert "exceeds" in out + + def test_max_file_size_none_copies_everything(self, hermes_home): + """Default (no cap) preserves manual /snapshot behavior.""" + from hermes_cli.backup import create_quick_snapshot + snap_id = create_quick_snapshot(hermes_home=hermes_home, max_file_size=None) + assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists() + + def test_max_file_size_under_cap_copies(self, hermes_home): + from hermes_cli.backup import create_quick_snapshot + snap_id = create_quick_snapshot( + hermes_home=hermes_home, max_file_size=1 << 30 + ) + assert (hermes_home / "state-snapshots" / snap_id / "state.db").exists() + def test_list_snapshots(self, hermes_home): from hermes_cli.backup import create_quick_snapshot, list_quick_snapshots id1 = create_quick_snapshot(label="first", hermes_home=hermes_home) @@ -2030,7 +2064,8 @@ class TestPreUpdateBackup: class TestRunPreUpdateBackup: """Tests for the ``_run_pre_update_backup`` wrapper in main.py — - covers config gate, ``--no-backup`` flag, and user-facing output.""" + covers the consolidated off/quick/full mode gate, CLI flags, and + user-facing output.""" @pytest.fixture def hermes_home(self, tmp_path, monkeypatch): @@ -2047,102 +2082,145 @@ class TestRunPreUpdateBackup: del __import__("sys").modules[mod] return root - def test_backup_flag_creates_backup(self, hermes_home, capsys): - """--backup forces the pre-update backup for one run even when config is off.""" - from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=False, backup=True)) - out = capsys.readouterr().out - assert "Creating pre-update backup" in out - assert "Saved:" in out - assert "Restore:" in out - assert "hermes import" in out - assert "Disable:" in out - # Actual backup was created - backups = list((hermes_home / "backups").glob("pre-update-*.zip")) - assert len(backups) == 1 + @staticmethod + def _set_mode(hermes_home, value): + import yaml + (hermes_home / "config.yaml").write_text(yaml.safe_dump({ + "_config_version": 22, + "updates": {"pre_update_backup": value}, + })) + import sys as _sys + for mod in list(_sys.modules.keys()): + if mod.startswith("hermes_cli.config"): + del _sys.modules[mod] - def test_default_disabled_is_silent(self, hermes_home, capsys): - """With the default (``pre_update_backup: false``), ``hermes update`` - does NOT create a backup and stays silent — zipping a large - HERMES_HOME can add minutes to every update. Users who want the - #48200 safety net opt in via the config knob or ``--backup``. - """ - from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=False, backup=False)) - out = capsys.readouterr().out - assert out == "" - assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \ - if (hermes_home / "backups").exists() else True + @staticmethod + def _zips(hermes_home): + d = hermes_home / "backups" + return list(d.glob("pre-update-*.zip")) if d.exists() else [] - def test_no_backup_flag_skips(self, hermes_home, capsys): + @staticmethod + def _snaps(hermes_home): + d = hermes_home / "state-snapshots" + return [p for p in d.iterdir() if p.is_dir()] if d.exists() else [] + + def test_default_creates_quick_snapshot_only(self, hermes_home, capsys): + """With no config, the default mode is ``quick``: a state snapshot is + created but NOT the full zip.""" from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=True, backup=False)) + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) out = capsys.readouterr().out - assert "skipped (--no-backup)" in out + assert snap_id is not None + assert "Pre-update snapshot" in out assert "Creating pre-update backup" not in out - # No backup written - assert not (hermes_home / "backups").exists() or not list( - (hermes_home / "backups").glob("pre-update-*.zip") - ) - - def test_config_enabled_creates_backup(self, hermes_home, capsys): - """Users who explicitly set updates.pre_update_backup: true still get - a backup on every update — this is the opt-in legacy behavior.""" - import yaml - (hermes_home / "config.yaml").write_text(yaml.safe_dump({ - "_config_version": 22, - "updates": {"pre_update_backup": True}, - })) - import sys as _sys - for mod in list(_sys.modules.keys()): - if mod.startswith("hermes_cli.config"): - del _sys.modules[mod] + assert self._snaps(hermes_home) + assert not self._zips(hermes_home) + def test_backup_flag_forces_full(self, hermes_home, capsys): + """--backup forces the full zip (plus quick snapshot) for one run.""" from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True)) out = capsys.readouterr().out + assert snap_id is not None + assert "Pre-update snapshot" in out assert "Creating pre-update backup" in out assert "Saved:" in out - backups = list((hermes_home / "backups").glob("pre-update-*.zip")) - assert len(backups) == 1 - - def test_config_disabled_is_silent(self, hermes_home, capsys): - """Explicit pre_update_backup: false behaves the same as the default — - silent no-op, no message spam.""" - import yaml - (hermes_home / "config.yaml").write_text(yaml.safe_dump({ - "_config_version": 22, - "updates": {"pre_update_backup": False}, - })) - # Ensure config module re-reads - import sys as _sys - for mod in list(_sys.modules.keys()): - if mod.startswith("hermes_cli.config"): - del _sys.modules[mod] + assert "hermes import" in out + assert len(self._zips(hermes_home)) == 1 + def test_no_backup_flag_skips_everything(self, hermes_home, capsys): + """--no-backup skips BOTH the quick snapshot and the zip.""" from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=False, backup=False)) - out = capsys.readouterr().out - assert out == "" - assert not list((hermes_home / "backups").glob("pre-update-*.zip")) \ - if (hermes_home / "backups").exists() else True - - def test_cli_flag_overrides_enabled_config(self, hermes_home, capsys): - """--no-backup wins even when config says pre_update_backup: true.""" - import yaml - (hermes_home / "config.yaml").write_text(yaml.safe_dump({ - "_config_version": 22, - "updates": {"pre_update_backup": True}, - })) - import sys as _sys - for mod in list(_sys.modules.keys()): - if mod.startswith("hermes_cli.config"): - del _sys.modules[mod] - - from hermes_cli.main import _run_pre_update_backup - _run_pre_update_backup(Namespace(no_backup=True, backup=False)) + snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False)) out = capsys.readouterr().out + assert snap_id is None assert "skipped (--no-backup)" in out + assert "Pre-update snapshot" not in out + assert not self._snaps(hermes_home) + assert not self._zips(hermes_home) + + def test_config_off_disables_everything_silently(self, hermes_home, capsys): + """pre_update_backup: off — an explicit opt-out disables the quick + snapshot too (it previously ran unconditionally), with no output.""" + self._set_mode(hermes_home, "off") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + out = capsys.readouterr().out + assert snap_id is None + assert out == "" + assert not self._snaps(hermes_home) + assert not self._zips(hermes_home) + + def test_legacy_false_maps_to_off(self, hermes_home, capsys): + """Legacy boolean ``false`` (the old zip opt-out) now means off.""" + self._set_mode(hermes_home, False) + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + assert snap_id is None + assert capsys.readouterr().out == "" + assert not self._snaps(hermes_home) + assert not self._zips(hermes_home) + + def test_legacy_true_maps_to_full(self, hermes_home, capsys): + """Legacy boolean ``true`` (the old always-zip opt-in) means full.""" + self._set_mode(hermes_home, True) + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + out = capsys.readouterr().out + assert snap_id is not None + assert "Creating pre-update backup" in out + assert "Saved:" in out + assert len(self._zips(hermes_home)) == 1 + + def test_config_full_mode(self, hermes_home, capsys): + self._set_mode(hermes_home, "full") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + out = capsys.readouterr().out + assert snap_id is not None + assert "Pre-update snapshot" in out + assert "Creating pre-update backup" in out + assert len(self._zips(hermes_home)) == 1 + + def test_config_quick_mode(self, hermes_home, capsys): + self._set_mode(hermes_home, "quick") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + out = capsys.readouterr().out + assert snap_id is not None + assert "Pre-update snapshot" in out + assert "Creating pre-update backup" not in out + assert not self._zips(hermes_home) + + def test_unknown_mode_falls_back_to_quick(self, hermes_home, capsys): + self._set_mode(hermes_home, "bogus-mode") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=False)) + out = capsys.readouterr().out + assert snap_id is not None + assert "Pre-update snapshot" in out + assert not self._zips(hermes_home) + + def test_no_backup_flag_overrides_full_config(self, hermes_home, capsys): + """--no-backup wins even when config says full.""" + self._set_mode(hermes_home, "full") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=True, backup=False)) + out = capsys.readouterr().out + assert snap_id is None + assert "skipped (--no-backup)" in out + assert not self._snaps(hermes_home) + assert not self._zips(hermes_home) + + def test_backup_flag_overrides_off_config(self, hermes_home, capsys): + """--backup wins over config off for a single run.""" + self._set_mode(hermes_home, "off") + from hermes_cli.main import _run_pre_update_backup + snap_id = _run_pre_update_backup(Namespace(no_backup=False, backup=True)) + out = capsys.readouterr().out + assert snap_id is not None + assert "Creating pre-update backup" in out + assert len(self._zips(hermes_home)) == 1 # --------------------------------------------------------------------------- diff --git a/website/docs/getting-started/updating.md b/website/docs/getting-started/updating.md index 7e7ea5b037a9..996dba189f3a 100644 --- a/website/docs/getting-started/updating.md +++ b/website/docs/getting-started/updating.md @@ -24,7 +24,7 @@ This pulls the latest code from `main`, updates dependencies, and prompts you to When you run `hermes update`, the following steps occur: -1. **Pairing-data snapshot** — a lightweight pre-update state snapshot is saved (covers `~/.hermes/pairing/`, Feishu comment rules, and other state files that get modified at runtime). Recoverable via the snapshot restore flow described under [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md), or by extracting the most recent quick-snapshot zip Hermes wrote next to your `~/.hermes/` directory. +1. **Pre-update snapshot** — a lightweight state snapshot is saved by default (covers pairing data, cron jobs, `config.yaml`, `.env`, `auth.json`, and other state files that get modified at runtime; individual files over 1 GiB are skipped so a large sessions DB never slows the update down). Controlled by `updates.pre_update_backup` (`quick` by default, `full` for a zip of all of `HERMES_HOME`, `off` to disable). Recoverable via the snapshot restore flow described under [Snapshots and rollback](../user-guide/checkpoints-and-rollback.md). 2. **Git pull** — pulls the latest code from the `main` branch and updates submodules 3. **Post-pull syntax validation + auto-rollback** — after the pull, Hermes compiles the eight critical files every `hermes` invocation imports at startup. If any fails to parse (e.g. an orphan merge-conflict marker, an accidentally truncated file), Hermes runs `git reset --hard ` to roll the install back so your shell stays bootable. Re-run `hermes update` once the upstream fix lands. 4. **Dependency install** — runs `uv pip install -e ".[all]"` to pick up new or changed dependencies @@ -77,10 +77,10 @@ Or make it the default for every run: ```yaml # ~/.hermes/config.yaml updates: - pre_update_backup: true + pre_update_backup: full ``` -`--backup` was the always-on behavior in earlier builds, but it was adding minutes to every update on large homes, so it's now opt-in. The lightweight pairing-data snapshot above still runs unconditionally. +`updates.pre_update_backup` is a single knob with three modes: `quick` (default — the lightweight state snapshot described above), `full` (the quick snapshot plus a complete `HERMES_HOME` zip; can add minutes on large homes), and `off` (no pre-update backup at all — `--no-backup` does the same for a single run). Legacy boolean values still work: `true` means `full`, `false` means `off`. ### Windows: another `hermes.exe` is running diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index e54264b8fdfa..def7e87799d7 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1562,8 +1562,8 @@ Pulls the latest `hermes-agent` code and reinstalls dependencies in the managed |--------|-------------| | `--gateway` | Internal mode used by the messaging `/update` command. Uses file-based IPC for prompts and progress streaming instead of reading from terminal stdin. Not a gateway restart flag. | | `--check` | Check whether an update is available without pulling, installing dependencies, or restarting anything. | -| `--no-backup` | Skip the pre-update backup for this run, even if `updates.pre_update_backup` is enabled in `config.yaml`. | -| `--backup` | Create a labeled pre-update snapshot of `HERMES_HOME` (config, auth, sessions, skills, pairing data) before pulling. Default is **off** — the previous always-backup behavior was adding minutes to every update on large homes. Flip it on permanently via `updates.pre_update_backup: true` in `config.yaml`. | +| `--no-backup` | Skip all pre-update backups for this run (both the quick state snapshot and the full zip), regardless of `updates.pre_update_backup`. | +| `--backup` | Force a **full** pre-update backup for this run: the quick state snapshot plus a complete zip of `HERMES_HOME` (config, auth, sessions, skills, pairing data). The default mode is `quick` — a lightweight state snapshot only. Set the permanent mode via `updates.pre_update_backup: quick | full | off` in `config.yaml`. | | `--yes`, `-y` | Assume yes for interactive prompts such as config migration and stash restore. API-key entry is skipped; run `hermes config migrate` separately for those. | Additional behavior: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 0be0ed61adfa..bb1eed0d85ec 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -101,11 +101,13 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM ```yaml updates: - pre_update_backup: false # Create a full HERMES_HOME zip before every update - backup_keep: 5 # Keep this many pre-update backup zips + pre_update_backup: quick # quick (state snapshot, default) | full (snapshot + HERMES_HOME zip) | off + backup_keep: 5 # Keep this many full pre-update backup zips non_interactive_local_changes: stash # stash | discard ``` +`pre_update_backup` is the single pre-update safety knob: `quick` (default) snapshots critical state files (pairing data, cron jobs, config, auth; files over 1 GiB are skipped) into `state-snapshots/`; `full` additionally zips all of `HERMES_HOME` into `backups/` and can add minutes on large homes; `off` disables both. Legacy booleans are honored (`true` → `full`, `false` → `off`). + For git installs, Hermes auto-stashes dirty tracked files and untracked files before checking out the update branch or pulling. Interactive terminal updates prompt before restoring that stash. Non-interactive updates (desktop/chat app, gateway, or `--yes`) use `updates.non_interactive_local_changes`: `stash` restores local source edits after a successful pull, while `discard` drops the update-created stash after a successful pull. Use `discard` only on managed installs where local source edits are never meant to persist. Before that stash step, Hermes also restores tracked `package-lock.json` diffs left by npm install/build churn. Commit or manually stash intentional lockfile edits before updating. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/updating.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/updating.md index 2fd205cb81a3..f3c1b9599267 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/updating.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/updating.md @@ -24,7 +24,7 @@ hermes update 运行 `hermes update` 时,将依次执行以下步骤: -1. **配对数据快照** — 保存一份轻量级的更新前状态快照(涵盖 `~/.hermes/pairing/`、飞书评论规则及其他运行时修改的状态文件)。可通过 [快照与回滚](../user-guide/checkpoints-and-rollback.md) 中描述的快照恢复流程进行恢复,或从 Hermes 写入 `~/.hermes/` 目录旁的最新快速快照 zip 文件中提取。 +1. **更新前快照** — 默认保存一份轻量级状态快照(涵盖配对数据、cron 任务、`config.yaml`、`.env`、`auth.json` 及其他运行时修改的状态文件;单个超过 1 GiB 的文件会被跳过,因此大型会话数据库不会拖慢更新)。由 `updates.pre_update_backup` 控制(默认 `quick`,`full` 为整个 `HERMES_HOME` 的 zip 备份,`off` 为禁用)。可通过 [快照与回滚](../user-guide/checkpoints-and-rollback.md) 中描述的快照恢复流程进行恢复。 2. **Git pull** — 从 `main` 分支拉取最新代码并更新子模块 3. **依赖安装** — 运行 `uv pip install -e ".[all]"` 以获取新增或变更的依赖项 4. **配置迁移** — 检测自当前版本以来新增的配置选项并提示设置 @@ -47,10 +47,10 @@ hermes update --backup ```yaml # ~/.hermes/config.yaml updates: - pre_update_backup: true + pre_update_backup: full ``` -`--backup` 在早期版本中是始终开启的行为,但在大型 home 目录上会给每次更新增加数分钟时间,因此现已改为按需启用。上述轻量级配对数据快照仍会无条件执行。 +`updates.pre_update_backup` 是单一开关,有三种模式:`quick`(默认 — 上述轻量级状态快照)、`full`(快速快照加上完整的 `HERMES_HOME` zip 备份;在大型 home 目录上可能增加数分钟)、`off`(完全不做更新前备份 — `--no-backup` 对单次运行有相同效果)。旧版布尔值仍然有效:`true` 等同于 `full`,`false` 等同于 `off`。 ### Windows:另一个 `hermes.exe` 正在运行