feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
mcp-server/tests/test_sync_verify_gate.py
Normal file
48
mcp-server/tests/test_sync_verify_gate.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
|
||||||
|
sync = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent(name, adapter="claude_code", cfg=None):
|
||||||
|
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
|
||||||
|
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
|
||||||
|
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_clean_is_zero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_drift_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_adapter_mismatch_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_exit_code_missing_is_nonzero():
|
||||||
|
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_adapter_mismatch():
|
||||||
|
master = [_agent("A", adapter="claude_code")]
|
||||||
|
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["mismatches"]
|
||||||
|
assert rep["plan"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_drift_report_flags_missing_and_plan():
|
||||||
|
master = [_agent("A"), _agent("B")]
|
||||||
|
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
|
||||||
|
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
|
||||||
|
assert "A" in rep["missing"]
|
||||||
|
assert any(p[0]["name"] == "B" for p in rep["plan"])
|
||||||
@@ -345,6 +345,33 @@ async def check_instructions(agents: list[dict]) -> bool:
|
|||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
|
||||||
|
"""Pure drift computation (no DB, no printing). Returns:
|
||||||
|
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
|
||||||
|
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
|
||||||
|
"""
|
||||||
|
plan, mismatches, missing = [], [], []
|
||||||
|
for m in master_agents:
|
||||||
|
if only and m["name"] != only:
|
||||||
|
continue
|
||||||
|
mirror = mirror_by_name.get(m["name"])
|
||||||
|
if not mirror:
|
||||||
|
missing.append(m["name"])
|
||||||
|
continue
|
||||||
|
if m["adapter_type"] != mirror["adapter_type"]:
|
||||||
|
mismatches.append(m["name"])
|
||||||
|
continue
|
||||||
|
diff = compute_diff(m, mirror, mirror_skills)
|
||||||
|
if diff:
|
||||||
|
plan.append((m, mirror, diff))
|
||||||
|
return {"plan": plan, "mismatches": mismatches, "missing": missing}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_exit_code(plan, mismatches, missing) -> int:
|
||||||
|
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
|
||||||
|
return 1 if (plan or mismatches or missing) else 0
|
||||||
|
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
g = p.add_mutually_exclusive_group(required=True)
|
g = p.add_mutually_exclusive_group(required=True)
|
||||||
@@ -376,26 +403,23 @@ async def main() -> None:
|
|||||||
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
|
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
|
||||||
|
|
||||||
print(f"=== Drift report ===")
|
print(f"=== Drift report ===")
|
||||||
plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff)
|
report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
|
||||||
for m in master_agents:
|
plan = report["plan"]
|
||||||
if args.only and m["name"] != args.only:
|
for name in report["missing"]:
|
||||||
continue
|
print(f" ⚠ {name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
|
||||||
mirror = mirror_by_name.get(m["name"])
|
for name in report["mismatches"]:
|
||||||
if not mirror:
|
m = next(a for a in master_agents if a["name"] == name)
|
||||||
print(f" ⚠ {m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)")
|
mi = mirror_by_name[name]
|
||||||
continue
|
print(f" ❌ {name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
|
||||||
if m["adapter_type"] != mirror["adapter_type"]:
|
f"— DRIFT (apply skips it; fix manually in both companies)")
|
||||||
print(f" ⚠ {m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING")
|
for master, mirror, diff in plan:
|
||||||
continue
|
print_diff(master["name"], diff, master["id"], mirror["id"])
|
||||||
diff = compute_diff(m, mirror, mirror_skills)
|
|
||||||
print_diff(m["name"], diff, m["id"], mirror["id"])
|
|
||||||
if diff:
|
|
||||||
plan.append((m, mirror, diff))
|
|
||||||
|
|
||||||
if args.verify:
|
if args.verify:
|
||||||
print(f"\n(verify mode — exiting without changes)")
|
code = _verify_exit_code(plan, report["mismatches"], report["missing"])
|
||||||
print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync")
|
print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
|
||||||
return
|
f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
|
||||||
|
sys.exit(code)
|
||||||
|
|
||||||
if not plan:
|
if not plan:
|
||||||
print(f"\n✓ All agents in sync — nothing to do.")
|
print(f"\n✓ All agents in sync — nothing to do.")
|
||||||
|
|||||||
Reference in New Issue
Block a user