mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(update): consolidate pre-update backups into one gated mechanism (#65754)
hermes update ran TWO separate pre-update backup mechanisms: the config-gated full zip (updates.pre_update_backup, default off) and an unconditional quick state snapshot added for #15733 that ignored the user's setting entirely. On a large state.db (observed: 24 GB) the 'cheap' snapshot silently added ~60s to every update and ate 24 GB of disk in state-snapshots/. Now there is ONE mechanism, gated by updates.pre_update_backup with three modes: - quick (new default): state snapshot of critical small files (pairing JSONs, cron jobs, config, auth, per-profile DBs). Files over 1 GiB are skipped with a warning so a bloated state.db can never stall the update again. - full: the quick snapshot plus the HERMES_HOME zip (old 'true' behavior; --backup forces it for one run). - off: nothing runs — an explicit opt-out now disables the quick snapshot too (--no-backup does the same per-run). Legacy booleans are honored: true -> full, false -> off. _run_pre_update_backup() now returns the quick-snapshot id so the post-update cron-jobs restore safety net (#34600) keeps working; the snapshot moved from the post-fetch site to the pre-mutation site, which also covers the zip-fallback update path it previously missed.
This commit is contained in:
parent
8462764367
commit
d0dcb9a5fd
9 changed files with 343 additions and 161 deletions
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue