fix(cron): restore jobs.json emptied by config migration on update
Config-version migrations have been observed to leave cron/jobs.json valid-but-empty after `hermes update`, silently dropping every scheduled job (#34600). The existing malformed-shape guards in cron/jobs.py don't catch this because {"jobs": []} is valid JSON. Add restore_cron_jobs_if_emptied() as a post-migration safety net: if the live cron/jobs.json now has zero jobs while the pre-update snapshot held one or more, restore the snapshot copy in place and warn loudly. The check is conservative — it only restores on unambiguous evidence of loss (snapshot had jobs, live file readable-and-empty), so a user who genuinely cleared their jobs is never second-guessed and an unreadable live file is left untouched so real corruption still surfaces. Wired into _cmd_update_impl after migrate_config(), reusing the existing pre-update quick snapshot (which already captures cron/jobs.json). Closes #34600
This commit is contained in:
@@ -1679,3 +1679,105 @@ class TestPreMigrationBackup:
|
||||
_t.sleep(1.05)
|
||||
# Update backup must still be there
|
||||
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron jobs auto-restore after silent migration loss (issue #34600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestoreCronJobsIfEmptied:
|
||||
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
|
||||
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
|
||||
post-migration safety net that restores from the pre-update snapshot."""
|
||||
|
||||
@staticmethod
|
||||
def _seed_jobs(path: Path, jobs):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"jobs": jobs}))
|
||||
|
||||
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
|
||||
|
||||
def test_restores_when_emptied_after_migration(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update: 3 real jobs.
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
assert snap_id
|
||||
|
||||
# Migration silently empties the file (valid JSON, zero jobs).
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["restored"] is True
|
||||
assert result["job_count"] == 3
|
||||
assert result["snapshot_id"] == snap_id
|
||||
|
||||
# The live file now has the jobs back.
|
||||
restored = json.loads(jobs_path.read_text())
|
||||
assert len(restored["jobs"]) == 3
|
||||
|
||||
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
# Healthy path: file unchanged after update.
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update genuinely had zero jobs; current is also empty.
|
||||
self._seed_jobs(jobs_path, [])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_live_file_unreadable(self, tmp_path):
|
||||
"""An unparseable live file is left alone — that's a different failure
|
||||
mode the user should see, not silently overwrite."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text("{ this is not valid json")
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
# File left untouched.
|
||||
assert jobs_path.read_text() == "{ this is not valid json"
|
||||
|
||||
def test_noop_when_snapshot_id_missing(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [])
|
||||
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
|
||||
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
|
||||
|
||||
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
|
||||
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
|
||||
still counted and restored."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
jobs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["job_count"] == 2
|
||||
|
||||
Reference in New Issue
Block a user