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:
Teknium
2026-04-27 00:19:12 -07:00
committed by GitHub
parent a32d07529c
commit 21f503c23c
3 changed files with 124 additions and 1 deletions

View File

@@ -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