mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-06 10:47:12 +08:00
* fix(curator): authoritative absorbed_into declarations on skill delete Closes #18671. The classification pipeline that feeds cron-ref rewriting used to infer consolidation vs pruning from two brittle signals: the curator model's post-hoc YAML summary block, and a substring heuristic scanning other tool calls for the removed skill's name. Both miss in real consolidations — the model forgets the YAML under reasoning pressure, and the heuristic misses when the umbrella's patch content describes the absorbed behavior abstractly instead of naming the old slug. When both miss, the skill falls through to 'no-evidence fallback' pruned, and #18253's cron rewriter drops the cron ref entirely instead of mapping it to the umbrella. Same observable symptom as pre-#18253: 'Skill(s) not found and skipped' at the next cron run. The fix makes the model declare intent at the moment of deletion. skill_manage(action='delete') now accepts absorbed_into: - absorbed_into='<umbrella>' -> consolidated, target must exist on disk - absorbed_into='' -> explicit prune, no forwarding target - missing -> legacy path, falls through to heuristic/YAML The curator reconciler reads these declarations off llm_meta.tool_calls BEFORE either the YAML block or the substring heuristic. Declaration wins. Fallback logic stays intact for backward compat with any caller (human or older curator conversation) that doesn't populate the arg. Changes - tools/skill_manager_tool.py: add absorbed_into param to skill_manage + _delete_skill. Validate target exists when non-empty. Reject absorbed_into=<self>. Wire through dispatcher + registry + schema. - agent/curator.py: new _extract_absorbed_into_declarations() walks tool calls for skill_manage(delete) with the arg. _reconcile_classification accepts absorbed_declarations= and treats them as authoritative. Curator prompt updated to require the arg on every delete. - Tests: 7 new skill_manager tests covering the tool contract (valid target, empty string, nonexistent target, self-reference, whitespace, backward compat, dispatcher plumbing). 11 new curator tests covering the extractor + authoritative reconciler path + mixed-legacy-and- declared runs. Validation - 307/307 targeted tests pass (curator + cron + skill_manager suites). - E2E #18671 repro: 3 narrow skills, 1 umbrella, cron job referencing all 3. Model emits NO YAML block. Heuristic misses (patch prose doesn't name old slugs). Delete calls carry absorbed_into. Result: both PR skills correctly classified 'consolidated' + cron rewritten ['pr-review-format', 'pr-review-checklist', 'stale-junk'] -> ['hermes-agent-dev']; stale-junk pruned via absorbed_into=''. - E2E backward-compat: delete without absorbed_into, model emits YAML -> routed via existing 'model' source, cron still rewritten correctly. * feat(curator): capture + restore cron skill links across snapshot/rollback Before this, rolling back a curator run restored the skills tree but cron jobs still pointed at the umbrella skills the curator had rewritten them to. The user would see their old narrow skills back on disk but their cron jobs still configured with the merged umbrella — not actually 'back to how it was'. Snapshot side: snapshot_skills() now captures ~/.hermes/cron/jobs.json alongside the skills tarball, as cron-jobs.json. The manifest gets a new 'cron_jobs' block with {backed_up, jobs_count} so rollback (and the CLI confirm dialog) can surface what's in the snapshot. If jobs.json is missing/unreadable/malformed, snapshot proceeds without cron data — the skills backup is the core guarantee; cron is additive. Rollback side: after the skills extract succeeds, the new _restore_cron_skill_links() reconciles the backed-up jobs into the live jobs.json SURGICALLY. Only 'skills' and 'skill' fields are restored, and only on jobs matched by id. Everything else about a cron job — schedule, last_run_at, next_run_at, enabled, prompt, workdir, hooks — is live state the user or scheduler has modified since the snapshot; overwriting it would regress unrelated activity. Reconciliation rules: - Job in backup AND live, skills differ → skills restored. - Job in backup AND live, skills match → no-op. - Job in backup, NOT in live → skipped (user deleted it after snapshot; their choice is later than the snapshot). - Job in live, NOT in backup → untouched (user created it after snapshot). - Snapshot missing cron-jobs.json at all → rollback still succeeds, reports 'not captured' (older pre-feature snapshots keep working). Writes go through cron.jobs.save_jobs under the same _jobs_file_lock the scheduler uses, so rollback doesn't race tick(). Also: - hermes_cli/curator.py: rollback confirm dialog now shows 'cron jobs: N (will be restored for skill-link fields only)' when the snapshot has cron data, or 'not in snapshot (<reason>)' otherwise. - rollback()'s message string includes a 'cron links: ...' clause summarizing the reconciliation outcome. Tests - 9 new cases: snapshot-with-cron, snapshot-without-cron, malformed-json captured-as-raw, full rollback-restores-skills-and-cron, rollback touches only skill fields, rollback skips user-deleted jobs, rollback leaves user-created jobs untouched, rollback still works with pre-feature snapshot that has no cron-jobs.json, standalone unit test on _restore_cron_skill_links exercising the full report shape. Validation - 484/484 targeted tests pass (curator + cron + skill_manager suites). - E2E: real snapshot_skills, real cron rewrite, real rollback. Before: ['pr-review-format', 'pr-review-checklist', 'pr-triage-salvage']. After curator: ['hermes-agent-dev']. After rollback: ['pr-review-format', 'pr-review-checklist', 'pr-triage-salvage']. Non-skill fields (id, name, prompt) preserved across the round trip.
595 lines
22 KiB
Python
595 lines
22 KiB
Python
"""Tests for agent/curator_backup.py — snapshot + rollback of the skills tree."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def backup_env(monkeypatch, tmp_path):
|
|
"""Isolate HERMES_HOME + reload modules so every test starts clean."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
(home / "skills").mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
# Reload so get_hermes_home picks up the env var fresh.
|
|
import hermes_constants
|
|
importlib.reload(hermes_constants)
|
|
from agent import curator_backup
|
|
importlib.reload(curator_backup)
|
|
return {"home": home, "skills": home / "skills", "cb": curator_backup}
|
|
|
|
|
|
def _write_skill(skills_dir: Path, name: str, body: str = "body") -> Path:
|
|
d = skills_dir / name
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: t\nversion: 1.0\n---\n\n{body}\n",
|
|
encoding="utf-8",
|
|
)
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# snapshot_skills
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_snapshot_creates_tarball_and_manifest(backup_env):
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
_write_skill(backup_env["skills"], "beta")
|
|
|
|
snap = cb.snapshot_skills(reason="test")
|
|
assert snap is not None, "snapshot should succeed with a populated skills dir"
|
|
assert (snap / "skills.tar.gz").exists()
|
|
manifest = json.loads((snap / "manifest.json").read_text())
|
|
assert manifest["reason"] == "test"
|
|
assert manifest["skill_files"] == 2
|
|
assert manifest["archive_bytes"] > 0
|
|
|
|
|
|
def test_snapshot_excludes_backups_dir_itself(backup_env):
|
|
"""The backup must NOT contain .curator_backups/ — that would recurse
|
|
with every subsequent snapshot and balloon disk usage."""
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
snap1 = cb.snapshot_skills(reason="first")
|
|
assert snap1 is not None
|
|
snap2 = cb.snapshot_skills(reason="second")
|
|
assert snap2 is not None
|
|
with tarfile.open(snap2 / "skills.tar.gz") as tf:
|
|
names = tf.getnames()
|
|
assert not any(n.startswith(".curator_backups") for n in names), (
|
|
"second snapshot must not contain the first snapshot recursively"
|
|
)
|
|
|
|
|
|
def test_snapshot_excludes_hub_dir(backup_env):
|
|
""".hub/ is managed by the skills hub. Rolling it back would break
|
|
lockfile invariants, so the snapshot omits it entirely."""
|
|
cb = backup_env["cb"]
|
|
hub = backup_env["skills"] / ".hub"
|
|
hub.mkdir()
|
|
(hub / "lock.json").write_text("{}")
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
snap = cb.snapshot_skills(reason="t")
|
|
assert snap is not None
|
|
with tarfile.open(snap / "skills.tar.gz") as tf:
|
|
names = tf.getnames()
|
|
assert not any(n.startswith(".hub") for n in names)
|
|
|
|
|
|
def test_snapshot_disabled_returns_none(backup_env, monkeypatch):
|
|
cb = backup_env["cb"]
|
|
monkeypatch.setattr(cb, "is_enabled", lambda: False)
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
assert cb.snapshot_skills() is None
|
|
# And no backup dir should have been created
|
|
assert not (backup_env["skills"] / ".curator_backups").exists()
|
|
|
|
|
|
def test_snapshot_uniquifies_when_same_second(backup_env, monkeypatch):
|
|
"""Two snapshots in the same wallclock second must not clobber each
|
|
other. The module appends a counter to the second snapshot's id."""
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
frozen = "2026-05-01T12-00-00Z"
|
|
monkeypatch.setattr(cb, "_utc_id", lambda now=None: frozen)
|
|
s1 = cb.snapshot_skills(reason="a")
|
|
s2 = cb.snapshot_skills(reason="b")
|
|
assert s1 is not None and s2 is not None
|
|
assert s1.name == frozen
|
|
assert s2.name == f"{frozen}-01"
|
|
|
|
|
|
def test_snapshot_prunes_to_keep_count(backup_env, monkeypatch):
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
monkeypatch.setattr(cb, "get_keep", lambda: 3)
|
|
|
|
# Create 5 snapshots with monotonically increasing fake ids
|
|
ids = [f"2026-05-0{i}T00-00-00Z" for i in range(1, 6)]
|
|
for i, fid in enumerate(ids):
|
|
monkeypatch.setattr(cb, "_utc_id", lambda now=None, _f=fid: _f)
|
|
cb.snapshot_skills(reason=f"n{i}")
|
|
|
|
remaining = sorted(p.name for p in (backup_env["skills"] / ".curator_backups").iterdir())
|
|
# Newest 3 kept (lex order == date order for this id format)
|
|
assert remaining == ids[2:], f"expected newest 3, got {remaining}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_backups / _resolve_backup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_list_backups_empty(backup_env):
|
|
cb = backup_env["cb"]
|
|
assert cb.list_backups() == []
|
|
|
|
|
|
def test_list_backups_returns_manifest_data(backup_env):
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
cb.snapshot_skills(reason="m1")
|
|
rows = cb.list_backups()
|
|
assert len(rows) == 1
|
|
assert rows[0]["reason"] == "m1"
|
|
assert rows[0]["skill_files"] == 1
|
|
|
|
|
|
def test_resolve_backup_newest_when_no_id(backup_env, monkeypatch):
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
ids = ["2026-05-01T00-00-00Z", "2026-05-02T00-00-00Z"]
|
|
for fid in ids:
|
|
monkeypatch.setattr(cb, "_utc_id", lambda now=None, _f=fid: _f)
|
|
cb.snapshot_skills()
|
|
resolved = cb._resolve_backup(None)
|
|
assert resolved is not None
|
|
assert resolved.name == "2026-05-02T00-00-00Z", (
|
|
"resolve(None) must return newest regular snapshot"
|
|
)
|
|
|
|
|
|
def test_resolve_backup_unknown_id_returns_none(backup_env):
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
cb.snapshot_skills()
|
|
assert cb._resolve_backup("not-an-id") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# rollback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rollback_restores_deleted_skill(backup_env):
|
|
"""The whole point of this feature: user loses a skill, rollback
|
|
brings it back."""
|
|
cb = backup_env["cb"]
|
|
skills = backup_env["skills"]
|
|
user_skill = _write_skill(skills, "my-personal-workflow", body="important content")
|
|
cb.snapshot_skills(reason="pre-simulated-curator")
|
|
|
|
# Simulate curator archiving it out of existence
|
|
import shutil as _sh
|
|
_sh.rmtree(user_skill)
|
|
assert not user_skill.exists()
|
|
|
|
ok, msg, _ = cb.rollback()
|
|
assert ok, f"rollback failed: {msg}"
|
|
assert user_skill.exists(), "my-personal-workflow should be restored"
|
|
assert "important content" in (user_skill / "SKILL.md").read_text()
|
|
|
|
|
|
def test_rollback_is_itself_undoable(backup_env):
|
|
"""A rollback creates its own safety snapshot before replacing the
|
|
tree, so the user can undo a mistaken rollback. The safety snapshot
|
|
is a real tarball with reason='pre-rollback to <id>' — it's
|
|
listed by list_backups() just like any other snapshot and can be
|
|
restored the same way."""
|
|
cb = backup_env["cb"]
|
|
skills = backup_env["skills"]
|
|
_write_skill(skills, "v1")
|
|
cb.snapshot_skills(reason="snapshot-of-v1")
|
|
|
|
# Overwrite with a new skill state
|
|
import shutil as _sh
|
|
_sh.rmtree(skills / "v1")
|
|
_write_skill(skills, "v2")
|
|
|
|
ok, _, _ = cb.rollback()
|
|
assert ok
|
|
assert (skills / "v1").exists()
|
|
|
|
# list_backups should show a safety snapshot tagged "pre-rollback to <target-id>"
|
|
rows = cb.list_backups()
|
|
pre_rollback_entries = [r for r in rows if "pre-rollback" in (r.get("reason") or "")]
|
|
assert len(pre_rollback_entries) >= 1, (
|
|
f"expected a pre-rollback safety snapshot in list_backups(), got: "
|
|
f"{[(r.get('id'), r.get('reason')) for r in rows]}"
|
|
)
|
|
# And the transient staging dir must be gone (it's implementation detail)
|
|
backups_dir = skills / ".curator_backups"
|
|
staging_dirs = [p for p in backups_dir.iterdir() if p.name.startswith(".rollback-staging-")]
|
|
assert staging_dirs == [], (
|
|
f"staging dir should be cleaned up on success, got: {staging_dirs}"
|
|
)
|
|
|
|
|
|
def test_rollback_no_snapshots_returns_error(backup_env):
|
|
cb = backup_env["cb"]
|
|
ok, msg, _ = cb.rollback()
|
|
assert not ok
|
|
assert "no matching backup" in msg.lower() or "no snapshot" in msg.lower()
|
|
|
|
|
|
def test_rollback_rejects_unsafe_tarball(backup_env, monkeypatch):
|
|
"""Tarballs with absolute paths or .. components must be refused even
|
|
if someone crafts a malicious snapshot. Defense in depth — normal
|
|
curator snapshots never produce these."""
|
|
cb = backup_env["cb"]
|
|
skills = backup_env["skills"]
|
|
_write_skill(skills, "alpha")
|
|
cb.snapshot_skills(reason="legit")
|
|
|
|
# Hand-craft a malicious tarball replacing the legit one
|
|
rows = cb.list_backups()
|
|
snap_dir = Path(rows[0]["path"])
|
|
mal = snap_dir / "skills.tar.gz"
|
|
mal.unlink()
|
|
with tarfile.open(mal, "w:gz") as tf:
|
|
evil = tempfile.NamedTemporaryFile(delete=False, suffix=".md")
|
|
evil.write(b"evil")
|
|
evil.close()
|
|
tf.add(evil.name, arcname="../../etc/evil.md")
|
|
os.unlink(evil.name)
|
|
|
|
ok, msg, _ = cb.rollback()
|
|
assert not ok
|
|
assert "unsafe" in msg.lower() or "refus" in msg.lower() or "extract" in msg.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration with run_curator_review
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_real_run_takes_pre_snapshot(backup_env, monkeypatch):
|
|
"""A real (non-dry) curator pass must snapshot the tree before calling
|
|
apply_automatic_transitions. This is the safety net #18373 asked for."""
|
|
cb = backup_env["cb"]
|
|
skills = backup_env["skills"]
|
|
_write_skill(skills, "alpha")
|
|
|
|
# Reload curator module against the freshly-env'd hermes_constants
|
|
from agent import curator
|
|
importlib.reload(curator)
|
|
|
|
# Stub out LLM review and auto transitions — we only care about the
|
|
# snapshot side-effect.
|
|
monkeypatch.setattr(
|
|
curator, "_run_llm_review",
|
|
lambda p: {"final": "", "summary": "s", "model": "", "provider": "",
|
|
"tool_calls": [], "error": None},
|
|
)
|
|
monkeypatch.setattr(
|
|
curator, "apply_automatic_transitions",
|
|
lambda now=None: {"checked": 1, "marked_stale": 0, "archived": 0, "reactivated": 0},
|
|
)
|
|
|
|
curator.run_curator_review(synchronous=True)
|
|
# Pre-run snapshot should exist
|
|
rows = cb.list_backups()
|
|
assert any(r.get("reason") == "pre-curator-run" for r in rows), (
|
|
f"expected a pre-curator-run snapshot, got {[r.get('reason') for r in rows]}"
|
|
)
|
|
|
|
|
|
def test_dry_run_skips_snapshot(backup_env, monkeypatch):
|
|
"""Dry-run previews must not spend disk on a snapshot — they don't
|
|
mutate anything, so there's nothing to back up."""
|
|
cb = backup_env["cb"]
|
|
skills = backup_env["skills"]
|
|
_write_skill(skills, "alpha")
|
|
|
|
from agent import curator
|
|
importlib.reload(curator)
|
|
monkeypatch.setattr(
|
|
curator, "_run_llm_review",
|
|
lambda p: {"final": "", "summary": "s", "model": "", "provider": "",
|
|
"tool_calls": [], "error": None},
|
|
)
|
|
|
|
curator.run_curator_review(synchronous=True, dry_run=True)
|
|
rows = cb.list_backups()
|
|
assert not any(r.get("reason") == "pre-curator-run" for r in rows), (
|
|
"dry-run must not create a pre-run snapshot"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cron-jobs backup + rollback (the part issue #18671's follow-up adds)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_cron_jobs(home: Path, jobs: list) -> Path:
|
|
"""Write a synthetic cron/jobs.json under HERMES_HOME. Returns the path.
|
|
Mirrors cron.jobs.save_jobs() wrapper shape: `{"jobs": [...], "updated_at": ...}`.
|
|
"""
|
|
cron_dir = home / "cron"
|
|
cron_dir.mkdir(parents=True, exist_ok=True)
|
|
path = cron_dir / "jobs.json"
|
|
path.write_text(
|
|
json.dumps({"jobs": jobs, "updated_at": "2026-05-01T00:00:00Z"}, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def _reload_cron_jobs(home: Path):
|
|
"""Reload cron.jobs so its module-level HERMES_DIR picks up the tmp HOME."""
|
|
import hermes_constants
|
|
importlib.reload(hermes_constants)
|
|
if "cron.jobs" in sys.modules:
|
|
import cron.jobs as _cj
|
|
importlib.reload(_cj)
|
|
else:
|
|
import cron.jobs as _cj # noqa: F401
|
|
import cron.jobs as cj
|
|
return cj
|
|
|
|
|
|
def test_snapshot_includes_cron_jobs(backup_env):
|
|
"""With a cron/jobs.json present, snapshot writes cron-jobs.json and records it in manifest."""
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
_write_cron_jobs(backup_env["home"], [
|
|
{"id": "job-a", "name": "a", "schedule": "every 1h", "skills": ["alpha"]},
|
|
{"id": "job-b", "name": "b", "schedule": "every 2h", "skill": "alpha"},
|
|
])
|
|
|
|
snap = cb.snapshot_skills(reason="test")
|
|
assert snap is not None
|
|
assert (snap / cb.CRON_JOBS_FILENAME).exists()
|
|
|
|
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
|
assert mf["cron_jobs"]["backed_up"] is True
|
|
assert mf["cron_jobs"]["jobs_count"] == 2
|
|
|
|
|
|
def test_snapshot_without_cron_jobs_file_still_succeeds(backup_env):
|
|
"""No cron/jobs.json on disk → snapshot succeeds, manifest records absence."""
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
# Deliberately do not create ~/.hermes/cron/jobs.json
|
|
|
|
snap = cb.snapshot_skills(reason="test")
|
|
assert snap is not None
|
|
assert not (snap / cb.CRON_JOBS_FILENAME).exists()
|
|
|
|
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
|
assert mf["cron_jobs"]["backed_up"] is False
|
|
assert "cron/jobs.json" in mf["cron_jobs"]["reason"]
|
|
|
|
|
|
def test_snapshot_cron_jobs_malformed_json_still_captured(backup_env):
|
|
"""Malformed jobs.json is still copied to the snapshot (fidelity over
|
|
validation); the manifest notes the parse warning."""
|
|
cb = backup_env["cb"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
(backup_env["home"] / "cron").mkdir()
|
|
(backup_env["home"] / "cron" / "jobs.json").write_text("{oh no", encoding="utf-8")
|
|
|
|
snap = cb.snapshot_skills(reason="test")
|
|
assert snap is not None
|
|
# Raw file was copied even though we couldn't parse it
|
|
assert (snap / cb.CRON_JOBS_FILENAME).read_text() == "{oh no"
|
|
|
|
mf = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
|
|
assert mf["cron_jobs"]["backed_up"] is True
|
|
assert mf["cron_jobs"]["jobs_count"] == 0
|
|
assert "parse_warning" in mf["cron_jobs"]
|
|
|
|
|
|
def test_rollback_restores_cron_skill_links(backup_env):
|
|
"""End-to-end: snapshot with job [alpha,beta], curator-style in-place
|
|
rewrite to [umbrella], then rollback → skills restored to [alpha,beta]."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
_write_skill(backup_env["skills"], "beta")
|
|
_write_skill(backup_env["skills"], "umbrella")
|
|
|
|
cj = _reload_cron_jobs(home)
|
|
cj.create_job(name="weekly", prompt="p", schedule="every 7d",
|
|
skills=["alpha", "beta"])
|
|
|
|
snap = cb.snapshot_skills(reason="pre-curator-run")
|
|
assert snap is not None
|
|
|
|
# Simulate the curator's in-place cron rewrite after consolidation
|
|
cj.rewrite_skill_refs(
|
|
consolidated={"alpha": "umbrella", "beta": "umbrella"},
|
|
pruned=[],
|
|
)
|
|
live_after_curator = cj.load_jobs()
|
|
assert live_after_curator[0]["skills"] == ["umbrella"]
|
|
|
|
# Now roll back
|
|
ok, msg, _ = cb.rollback(backup_id=snap.name)
|
|
assert ok, msg
|
|
assert "cron links" in msg
|
|
|
|
live_after_rollback = cj.load_jobs()
|
|
# skills restored; legacy `skill` mirror follows first element
|
|
assert live_after_rollback[0]["skills"] == ["alpha", "beta"]
|
|
|
|
|
|
def test_rollback_only_touches_skill_fields(backup_env):
|
|
"""Every field other than skills/skill must remain untouched across rollback.
|
|
Schedule, enabled, prompt, timestamps — all live state, hands off."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
|
|
# Hand-rolled jobs.json with varied fields (no real create_job — we want
|
|
# exact field control).
|
|
_write_cron_jobs(home, [{
|
|
"id": "stable-id",
|
|
"name": "original-name",
|
|
"prompt": "original prompt",
|
|
"schedule": "every 1h",
|
|
"skills": ["alpha"],
|
|
"enabled": True,
|
|
"last_run_at": "2026-04-01T00:00:00Z",
|
|
}])
|
|
snap = cb.snapshot_skills(reason="pre-curator-run")
|
|
assert snap is not None
|
|
|
|
# User/scheduler activity AFTER the snapshot: rename the job, change
|
|
# the schedule, update timestamps, and (curator) rewrite the skills list.
|
|
cj = _reload_cron_jobs(home)
|
|
jobs = cj.load_jobs()
|
|
jobs[0]["name"] = "renamed-since-snapshot"
|
|
jobs[0]["schedule"] = "every 30m"
|
|
jobs[0]["last_run_at"] = "2026-05-01T12:00:00Z"
|
|
jobs[0]["skills"] = ["umbrella"] # pretend curator did this
|
|
cj.save_jobs(jobs)
|
|
|
|
ok, _, _ = cb.rollback(backup_id=snap.name)
|
|
assert ok
|
|
|
|
after = cj.load_jobs()
|
|
job = after[0]
|
|
# skills: restored
|
|
assert job["skills"] == ["alpha"]
|
|
# everything else: untouched (live state preserved)
|
|
assert job["name"] == "renamed-since-snapshot"
|
|
assert job["schedule"] == "every 30m"
|
|
assert job["last_run_at"] == "2026-05-01T12:00:00Z"
|
|
assert job["prompt"] == "original prompt"
|
|
|
|
|
|
def test_rollback_skips_jobs_the_user_deleted(backup_env):
|
|
"""If the user deleted a cron job after the snapshot, rollback must
|
|
NOT resurrect it — the user's delete is a later, explicit choice."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
|
|
_write_cron_jobs(home, [
|
|
{"id": "keep-me", "name": "keep", "schedule": "every 1h", "skills": ["alpha"]},
|
|
{"id": "delete-me", "name": "gone", "schedule": "every 1h", "skills": ["alpha"]},
|
|
])
|
|
snap = cb.snapshot_skills(reason="pre-curator-run")
|
|
|
|
# User deletes one job after the snapshot
|
|
cj = _reload_cron_jobs(home)
|
|
cj.save_jobs([j for j in cj.load_jobs() if j["id"] != "delete-me"])
|
|
|
|
ok, _, _ = cb.rollback(backup_id=snap.name)
|
|
assert ok
|
|
|
|
live_after = cj.load_jobs()
|
|
live_ids = {j["id"] for j in live_after}
|
|
assert "keep-me" in live_ids
|
|
assert "delete-me" not in live_ids # not resurrected
|
|
|
|
|
|
def test_rollback_leaves_new_jobs_untouched(backup_env):
|
|
"""Jobs created AFTER the snapshot must pass through rollback unchanged."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
_write_cron_jobs(home, [
|
|
{"id": "original", "name": "o", "schedule": "every 1h", "skills": ["alpha"]},
|
|
])
|
|
snap = cb.snapshot_skills(reason="pre-curator-run")
|
|
|
|
cj = _reload_cron_jobs(home)
|
|
jobs = cj.load_jobs()
|
|
jobs.append({"id": "new-after-snapshot", "name": "new",
|
|
"schedule": "every 15m", "skills": ["brand-new-skill"]})
|
|
cj.save_jobs(jobs)
|
|
|
|
ok, _, _ = cb.rollback(backup_id=snap.name)
|
|
assert ok
|
|
|
|
live = cj.load_jobs()
|
|
by_id = {j["id"]: j for j in live}
|
|
assert "new-after-snapshot" in by_id
|
|
# New job's fields completely preserved
|
|
assert by_id["new-after-snapshot"]["skills"] == ["brand-new-skill"]
|
|
assert by_id["new-after-snapshot"]["schedule"] == "every 15m"
|
|
|
|
|
|
def test_rollback_with_snapshot_missing_cron_succeeds(backup_env):
|
|
"""Older snapshots (created before this feature shipped) have no
|
|
cron-jobs.json. Rollback must still restore the skills tree and not
|
|
error out."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
_write_skill(backup_env["skills"], "alpha")
|
|
|
|
# No cron/jobs.json at snapshot time — simulates a pre-feature snapshot
|
|
snap = cb.snapshot_skills(reason="test")
|
|
assert snap is not None
|
|
assert not (snap / cb.CRON_JOBS_FILENAME).exists()
|
|
|
|
# Later the user created a cron job
|
|
_write_cron_jobs(home, [
|
|
{"id": "later-job", "name": "l", "schedule": "every 1h", "skills": ["x"]},
|
|
])
|
|
|
|
ok, msg, _ = cb.rollback(backup_id=snap.name)
|
|
# Main rollback still succeeds; cron report notes the missing file.
|
|
assert ok, msg
|
|
# Jobs.json untouched (nothing to restore from)
|
|
cj = _reload_cron_jobs(home)
|
|
jobs = cj.load_jobs()
|
|
assert jobs[0]["id"] == "later-job"
|
|
assert jobs[0]["skills"] == ["x"]
|
|
|
|
|
|
def test_restore_cron_skill_links_standalone(backup_env):
|
|
"""Unit-level test on _restore_cron_skill_links without the full rollback.
|
|
Verifies the report structure carefully."""
|
|
cb = backup_env["cb"]
|
|
home = backup_env["home"]
|
|
|
|
# Prime a snapshot dir manually with cron-jobs.json
|
|
backups_dir = home / "skills" / ".curator_backups" / "fake-id"
|
|
backups_dir.mkdir(parents=True)
|
|
(backups_dir / cb.CRON_JOBS_FILENAME).write_text(json.dumps([
|
|
{"id": "job-1", "name": "one", "skills": ["narrow-a", "narrow-b"]},
|
|
{"id": "job-2", "name": "two", "skill": "legacy-single"},
|
|
{"id": "job-gone", "name": "deleted", "skills": ["whatever"]},
|
|
]), encoding="utf-8")
|
|
|
|
# Live jobs: job-1 got rewritten, job-2 unchanged, job-gone deleted
|
|
_write_cron_jobs(home, [
|
|
{"id": "job-1", "name": "one", "skills": ["umbrella"], "schedule": "every 1h"},
|
|
{"id": "job-2", "name": "two", "skill": "legacy-single", "schedule": "every 1h"},
|
|
{"id": "job-new", "name": "new", "skills": ["x"], "schedule": "every 1h"},
|
|
])
|
|
_reload_cron_jobs(home)
|
|
|
|
report = cb._restore_cron_skill_links(backups_dir)
|
|
assert report["attempted"] is True
|
|
assert report["error"] is None
|
|
assert report["unchanged"] == 1 # job-2 matched
|
|
assert len(report["restored"]) == 1 # job-1 got restored
|
|
assert report["restored"][0]["job_id"] == "job-1"
|
|
assert report["restored"][0]["to"]["skills"] == ["narrow-a", "narrow-b"]
|
|
assert len(report["skipped_missing"]) == 1
|
|
assert report["skipped_missing"][0]["job_id"] == "job-gone"
|