diff --git a/mcp-server/tests/test_sync_verify_gate.py b/mcp-server/tests/test_sync_verify_gate.py new file mode 100644 index 0000000..cca3e1b --- /dev/null +++ b/mcp-server/tests/test_sync_verify_gate.py @@ -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"]) diff --git a/scripts/sync_agents_across_companies.py b/scripts/sync_agents_across_companies.py index 5f00c0f..6273bcf 100644 --- a/scripts/sync_agents_across_companies.py +++ b/scripts/sync_agents_across_companies.py @@ -345,6 +345,33 @@ async def check_instructions(agents: list[dict]) -> bool: 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: p = argparse.ArgumentParser() 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"=== Drift report ===") - plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff) - for m in master_agents: - if args.only and m["name"] != args.only: - continue - mirror = mirror_by_name.get(m["name"]) - if not mirror: - print(f" ⚠ {m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)") - continue - if m["adapter_type"] != mirror["adapter_type"]: - print(f" ⚠ {m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING") - continue - diff = compute_diff(m, mirror, mirror_skills) - print_diff(m["name"], diff, m["id"], mirror["id"]) - if diff: - plan.append((m, mirror, diff)) + report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only) + plan = report["plan"] + for name in report["missing"]: + print(f" ⚠ {name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT") + for name in report["mismatches"]: + m = next(a for a in master_agents if a["name"] == name) + mi = mirror_by_name[name] + print(f" ❌ {name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) " + f"— DRIFT (apply skips it; fix manually in both companies)") + for master, mirror, diff in plan: + print_diff(master["name"], diff, master["id"], mirror["id"]) if args.verify: - print(f"\n(verify mode — exiting without changes)") - print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync") - return + code = _verify_exit_code(plan, report["mismatches"], report["missing"]) + print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, " + f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}") + sys.exit(code) if not plan: print(f"\n✓ All agents in sync — nothing to do.")