refactor(update): fold the auto-restore sequence into a shared helper

Addresses review feedback on the regression test. The test previously parsed
the update_cmd.py AST to assert that each auto-restore call site cleared the
destination's sidecars before copying. That bound the fix to source text rather
than behaviour, and would break on unrelated refactors.

Extract _restore_state_db_from_snapshot(state_path, snap_state), which performs
the clear -> copy -> verify sequence as one unit and returns whether the
restored file passes its integrity check. Both auto-restore paths now call it,
so the ordering is guaranteed by construction instead of by inspection, and the
two byte-identical blocks collapse to a single call each.

The regression test now exercises that helper directly against a database that
still owns a hot WAL: removing the clear from inside the helper fails it with
201 rows where 400 were expected, so the guard remains bound to behaviour.

Also covers the two failure modes the callers already handle: a snapshot that
does not survive the copy returns False, and a missing snapshot raises OSError.
This commit is contained in:
briandevans
2026-08-04 05:23:43 -07:00
committed by Teknium
parent 86d719067b
commit 33dce0eb7e
2 changed files with 58 additions and 72 deletions
+27 -20
View File
@@ -1516,6 +1516,27 @@ def _write_gateway_update_exit_code(ok: bool) -> None:
pass
def _restore_state_db_from_snapshot(state_path: Path, snap_state: Path) -> bool:
"""Replace *state_path* with the snapshot image at *snap_state*.
Shared by both post-update auto-restore paths (the ZIP update and the git
pull). The destination's stale sidecars are cleared before the copy, so the
restored image cannot be silently overwritten by the corrupt database's WAL
replay — see :func:`_clear_stale_sqlite_sidecars`.
Returns ``True`` when the restored file passes an integrity check. Raises
``OSError`` if the copy itself fails, which callers already report.
"""
from hermes_cli.backup import verify_sqlite_integrity
_clear_stale_sqlite_sidecars(state_path)
shutil.copy2(snap_state, state_path)
restored = verify_sqlite_integrity(
state_path, check_header=True, run_pragma=True
)
return bool(restored.get("valid"))
def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> bool:
"""Update Hermes Agent by downloading a ZIP archive.
@@ -1897,16 +1918,9 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
)
if _snap_ok.get("valid"):
try:
import shutil as _shutil
_clear_stale_sqlite_sidecars(_state_path)
_shutil.copy2(_snap_state, _state_path)
_restored_ok = verify_sqlite_integrity(
_state_path,
check_header=True,
run_pragma=True,
)
if _restored_ok.get("valid"):
if _restore_state_db_from_snapshot(
_state_path, _snap_state
):
print(
" ✓ Auto-restored from snapshot "
f"{_snap_dir.name}"
@@ -7104,16 +7118,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
if _snap_ok.get("valid"):
try:
import shutil as _shutil
_clear_stale_sqlite_sidecars(_state_path)
_shutil.copy2(_snap_state, _state_path)
_restored_ok = verify_sqlite_integrity(
_state_path,
check_header=True,
run_pragma=True,
)
if _restored_ok.get("valid"):
if _restore_state_db_from_snapshot(
_state_path, _snap_state
):
print(
" ✓ Auto-restored from pre-update "
f"snapshot ({_pre_snap_id})"
@@ -18,14 +18,16 @@ These tests exercise REAL SQLite files, in WAL mode, with a genuinely hot
sidecar.
"""
import ast
import shutil
import sqlite3
from pathlib import Path
import pytest
from hermes_cli.update_cmd import _clear_stale_sqlite_sidecars
from hermes_cli.update_cmd import (
_clear_stale_sqlite_sidecars,
_restore_state_db_from_snapshot,
)
OLD_ROWS = 201
SNAPSHOT_ROWS = 400
@@ -174,61 +176,38 @@ def test_clear_removes_every_sidecar_suffix_and_spares_the_database(tmp_path):
assert db_path.read_bytes() == b"main-db"
def test_both_auto_restore_call_sites_clear_sidecars_first():
"""Bind the fix to the production call sites, not just the helper.
def test_restore_helper_serves_snapshot_rows_over_a_hot_wal(
live_db_with_hot_wal, snapshot_db
):
"""The shared restore helper is what both update paths call.
Both auto-restore blocks (the ZIP-update path and the git-pull path) copy
the snapshot with ``_shutil.copy2(_snap_state, _state_path)``. Each one must
be immediately preceded by the sidecar clear, or the helper is dead code.
It must clear, copy and verify as one unit: after it returns, the database
has to hold the SNAPSHOT's rows even though the destination still owned a
hot WAL from the corrupt database.
"""
source = Path(__file__).resolve().parents[2] / "hermes_cli" / "update_cmd.py"
tree = ast.parse(source.read_text(encoding="utf-8"))
assert _restore_state_db_from_snapshot(live_db_with_hot_wal, snapshot_db) is True
guarded = 0
for node in ast.walk(tree):
body = getattr(node, "body", None)
if not isinstance(body, list):
continue
for previous, statement in zip(body, body[1:]):
if not _is_snapshot_copy(statement):
continue
assert _is_sidecar_clear(previous), (
"auto-restore at line "
f"{statement.lineno} copies the snapshot without clearing the "
"destination's stale SQLite sidecars first"
)
guarded += 1
assert guarded == 2, f"expected 2 auto-restore call sites, found {guarded}"
assert _row_count(live_db_with_hot_wal) == SNAPSHOT_ROWS
for suffix in ("-wal", "-shm", "-journal"):
assert not _sidecar(live_db_with_hot_wal, suffix).exists()
def _is_snapshot_copy(statement) -> bool:
call = _expression_call(statement)
if call is None:
return False
func = call.func
return (
isinstance(func, ast.Attribute)
and func.attr == "copy2"
and isinstance(func.value, ast.Name)
and func.value.id == "_shutil"
and bool(call.args)
and isinstance(call.args[0], ast.Name)
and call.args[0].id == "_snap_state"
)
def test_restore_helper_reports_failure_when_the_restored_copy_is_corrupt(tmp_path):
"""A snapshot that does not survive the copy must return False, so the
caller prints the failure branch instead of claiming success."""
state_path = tmp_path / "state.db"
state_path.write_bytes(b"whatever")
bad_snapshot = tmp_path / "bad-snapshot.db"
bad_snapshot.write_bytes(b"\x00" * 4096)
assert _restore_state_db_from_snapshot(state_path, bad_snapshot) is False
def _is_sidecar_clear(statement) -> bool:
call = _expression_call(statement)
if call is None:
return False
return (
isinstance(call.func, ast.Name)
and call.func.id == "_clear_stale_sqlite_sidecars"
)
def test_restore_helper_propagates_copy_errors(tmp_path):
"""A missing snapshot raises OSError, which both call sites already catch
and report as 'Auto-restore file copy failed'."""
state_path = tmp_path / "state.db"
state_path.write_bytes(b"whatever")
def _expression_call(statement):
if isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call):
return statement.value
return None
with pytest.raises(OSError):
_restore_state_db_from_snapshot(state_path, tmp_path / "does-not-exist.db")