mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(update): snapshot pairing data before git pull (#16383)
Quick state snapshot now includes pairing JSONs (generic + legacy + Feishu comment pairing), and `hermes update` takes a pre-update snapshot labeled `pre-update` before pulling. Pairing data lives outside state.db in platform-specific JSONs under ~/.hermes/pairing/, ~/.hermes/platforms/pairing/, and ~/.hermes/feishu_comment_pairing.json. The update command already couldn't touch $HERMES_HOME, but #15733 reports lost pairing after an update — this gives users something to restore from via `/snapshot list` / `/snapshot restore <id>` if anything clobbers the approved-user lists. - Extend _QUICK_STATE_FILES with pairing paths (files + dirs) - Snapshot walks directories recursively and records each file in the manifest individually so restore logic is unchanged - _cmd_update_impl calls create_quick_snapshot(label='pre-update') after 'Found N new commits' and before 'Pulling updates' - Snapshot failures are logged at debug and never block the update Refs #15733.
This commit is contained in:
@@ -454,6 +454,12 @@ def run_import(args) -> None:
|
||||
# Critical state files to include in quick snapshots (relative to HERMES_HOME).
|
||||
# Everything else is either regeneratable (logs, cache) or managed separately
|
||||
# (skills, repo, sessions/).
|
||||
#
|
||||
# Entries may be individual files OR directories. Directories are captured
|
||||
# recursively; missing entries are silently skipped. Pairing data lives in
|
||||
# platform-specific JSON blobs outside state.db, so it's listed here explicitly
|
||||
# — `hermes update` snapshots this set before pulling so approved-user lists
|
||||
# are recoverable if anything goes wrong (issue #15733).
|
||||
_QUICK_STATE_FILES = (
|
||||
"state.db",
|
||||
"config.yaml",
|
||||
@@ -463,6 +469,10 @@ _QUICK_STATE_FILES = (
|
||||
"gateway_state.json",
|
||||
"channel_directory.json",
|
||||
"processes.json",
|
||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||
"pairing", # legacy location (gateway/pairing.py)
|
||||
"platforms/pairing", # new location (gateway/pairing.py)
|
||||
"feishu_comment_pairing.json", # Feishu comment subscription pairings
|
||||
)
|
||||
|
||||
_QUICK_SNAPSHOTS_DIR = "state-snapshots"
|
||||
@@ -498,7 +508,27 @@ def create_quick_snapshot(
|
||||
|
||||
for rel in _QUICK_STATE_FILES:
|
||||
src = home / rel
|
||||
if not src.exists() or not src.is_file():
|
||||
if not src.exists():
|
||||
continue
|
||||
|
||||
if src.is_dir():
|
||||
# Walk the directory and record each file individually in the
|
||||
# manifest so restore can treat them uniformly. Empty dirs are
|
||||
# skipped (nothing to snapshot).
|
||||
for sub in src.rglob("*"):
|
||||
if not sub.is_file():
|
||||
continue
|
||||
sub_rel = sub.relative_to(home).as_posix()
|
||||
dst = snap_dir / sub_rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copy2(sub, dst)
|
||||
manifest[sub_rel] = dst.stat().st_size
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.warning("Could not snapshot %s: %s", sub_rel, exc)
|
||||
continue
|
||||
|
||||
if not src.is_file():
|
||||
continue
|
||||
|
||||
dst = snap_dir / rel
|
||||
|
||||
@@ -6333,6 +6333,22 @@ 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 <id>`.
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snap_id = create_quick_snapshot(label="pre-update")
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_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
|
||||
try:
|
||||
|
||||
@@ -1141,3 +1141,80 @@ class TestQuickSnapshot:
|
||||
deleted = prune_quick_snapshots(keep=3, hermes_home=hermes_home)
|
||||
assert deleted == 7
|
||||
assert len(list_quick_snapshots(hermes_home=hermes_home)) == 3
|
||||
|
||||
def test_snapshot_includes_pairing_directories(self, hermes_home):
|
||||
"""Pairing JSONs live outside state.db — snapshot must capture them
|
||||
recursively (generic + per-platform) so approved-user lists survive
|
||||
disasters like #15733."""
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
# Generic pairing store (new location)
|
||||
(hermes_home / "platforms" / "pairing").mkdir(parents=True)
|
||||
(hermes_home / "platforms" / "pairing" / "telegram-approved.json").write_text(
|
||||
'{"12345": {"user_name": "alice"}}'
|
||||
)
|
||||
(hermes_home / "platforms" / "pairing" / "discord-approved.json").write_text(
|
||||
'{"67890": {"user_name": "bob"}}'
|
||||
)
|
||||
# Legacy pairing store (old location)
|
||||
(hermes_home / "pairing").mkdir()
|
||||
(hermes_home / "pairing" / "matrix-approved.json").write_text(
|
||||
'{"@charlie:server": {"user_name": "charlie"}}'
|
||||
)
|
||||
# Feishu's separate JSON
|
||||
(hermes_home / "feishu_comment_pairing.json").write_text(
|
||||
'{"doc_abc": {"allow_from": ["user_xyz"]}}'
|
||||
)
|
||||
|
||||
snap_id = create_quick_snapshot(hermes_home=hermes_home)
|
||||
assert snap_id is not None
|
||||
|
||||
snap_dir = hermes_home / "state-snapshots" / snap_id
|
||||
assert (snap_dir / "platforms" / "pairing" / "telegram-approved.json").exists()
|
||||
assert (snap_dir / "platforms" / "pairing" / "discord-approved.json").exists()
|
||||
assert (snap_dir / "pairing" / "matrix-approved.json").exists()
|
||||
assert (snap_dir / "feishu_comment_pairing.json").exists()
|
||||
|
||||
with open(snap_dir / "manifest.json") as f:
|
||||
meta = json.load(f)
|
||||
files = meta["files"]
|
||||
assert "platforms/pairing/telegram-approved.json" in files
|
||||
assert "platforms/pairing/discord-approved.json" in files
|
||||
assert "pairing/matrix-approved.json" in files
|
||||
assert "feishu_comment_pairing.json" in files
|
||||
|
||||
def test_restore_recovers_pairing_data(self, hermes_home):
|
||||
"""After restore, deleted pairing files reappear with original content."""
|
||||
from hermes_cli.backup import create_quick_snapshot, restore_quick_snapshot
|
||||
|
||||
pairing_dir = hermes_home / "platforms" / "pairing"
|
||||
pairing_dir.mkdir(parents=True)
|
||||
approved = pairing_dir / "telegram-approved.json"
|
||||
approved.write_text('{"12345": {"user_name": "alice"}}')
|
||||
feishu = hermes_home / "feishu_comment_pairing.json"
|
||||
feishu.write_text('{"doc_abc": {"allow_from": ["user_xyz"]}}')
|
||||
|
||||
snap_id = create_quick_snapshot(hermes_home=hermes_home)
|
||||
assert snap_id is not None
|
||||
|
||||
# Simulate the disaster — user loses both pairing files.
|
||||
approved.unlink()
|
||||
feishu.unlink()
|
||||
assert not approved.exists()
|
||||
assert not feishu.exists()
|
||||
|
||||
assert restore_quick_snapshot(snap_id, hermes_home=hermes_home) is True
|
||||
assert approved.exists()
|
||||
assert '"alice"' in approved.read_text()
|
||||
assert feishu.exists()
|
||||
assert '"user_xyz"' in feishu.read_text()
|
||||
|
||||
def test_empty_pairing_dir_does_not_fail(self, hermes_home):
|
||||
"""An empty pairing directory should be silently skipped."""
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
(hermes_home / "platforms" / "pairing").mkdir(parents=True)
|
||||
# Directory exists but contains no files.
|
||||
snap_id = create_quick_snapshot(hermes_home=hermes_home)
|
||||
# Other state still present → snapshot succeeds.
|
||||
assert snap_id is not None
|
||||
|
||||
Reference in New Issue
Block a user