#!/usr/bin/env python3 """sync_missing_agent_skills.py — One-shot fix for Gap #28. Adds the missing paperclipSkillSync to הגהת מסמכים and מנתח משפטי in both companies (1xxx CMP, 8xxx CMPA). Idempotent: safe to re-run. Design: "אל-כשל" — backup, dry-run mode, idempotent, clear errors. Usage: python sync_missing_agent_skills.py --dry-run # show plan only python sync_missing_agent_skills.py --apply # actually do it python sync_missing_agent_skills.py --verify # check current state """ from __future__ import annotations import argparse import asyncio import json import os import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any import asyncpg import httpx PAPERCLIP_DB_URL = os.environ.get( "PAPERCLIP_DB_URL", "postgresql://paperclip:paperclip@127.0.0.1:54329/paperclip" ) PAPERCLIP_API_URL = os.environ.get("PAPERCLIP_API_URL", "http://localhost:3100") PAPERCLIP_BOARD_API_KEY = os.environ.get("PAPERCLIP_BOARD_API_KEY", "") BACKUP_DIR = Path("/home/chaim/.paperclip/instances/default/data/backups/manual") PAPERCLIP_BASE_SKILLS = [ "paperclipai/paperclip/paperclip", "paperclipai/paperclip/paperclip-create-agent", "paperclipai/paperclip/paperclip-create-plugin", "paperclipai/paperclip/para-memory-files", ] CMP_COMPANY_ID = "42a7acd0-30c5-4cbd-ac97-7424f65df294" # 1xxx — רישוי ובניה CMPA_COMPANY_ID = "8639e837-4c9d-47fa-a76b-95788d651896" # 8xxx — היטלי השבחה # Per-agent + per-company desired skills PLAN: dict[tuple[str, str], list[str]] = { # (agent_name, company_id) -> desired skills ("מנתח משפטי", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS + ["local/eba6210d5a/legal-decision"], ("מנתח משפטי", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS, # CMPA has no local skills ("הגהת מסמכים", CMP_COMPANY_ID): PAPERCLIP_BASE_SKILLS, ("הגהת מסמכים", CMPA_COMPANY_ID): PAPERCLIP_BASE_SKILLS, } def fail(msg: str) -> None: print(f"❌ {msg}", file=sys.stderr) sys.exit(1) async def fetch_targets() -> list[dict[str, Any]]: """Return rows for the agents we plan to update.""" conn = await asyncpg.connect(PAPERCLIP_DB_URL) try: rows = await conn.fetch( """ SELECT a.id, a.name, a.company_id::text as company_id, COALESCE( jsonb_array_length(a.adapter_config->'paperclipSkillSync'->'desiredSkills'), 0 ) as current_skill_count FROM agents a WHERE a.name IN ('מנתח משפטי', 'הגהת מסמכים') ORDER BY a.name, a.company_id """ ) finally: await conn.close() return [dict(r) for r in rows] def backup_agents_table() -> Path: BACKUP_DIR.mkdir(parents=True, exist_ok=True) stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") out = BACKUP_DIR / f"agents-pre-skill-sync-{stamp}.sql" env = {**os.environ, "PGPASSWORD": "paperclip"} subprocess.run( ["pg_dump", "-h", "127.0.0.1", "-p", "54329", "-U", "paperclip", "-d", "paperclip", "-t", "agents", "--data-only", "-f", str(out)], check=True, env=env, ) return out async def call_skill_sync(agent_id: str, desired_skills: list[str]) -> tuple[int, dict[str, Any]]: """Call POST /api/agents/{id}/skills/sync with the desired skills list.""" if not PAPERCLIP_BOARD_API_KEY: fail("PAPERCLIP_BOARD_API_KEY not set — needed for /api/agents/.../skills/sync") url = f"{PAPERCLIP_API_URL}/api/agents/{agent_id}/skills/sync" headers = { "Authorization": f"Bearer {PAPERCLIP_BOARD_API_KEY}", "X-Paperclip-Run-Id": "", "Content-Type": "application/json", } body = {"desiredSkills": desired_skills} async with httpx.AsyncClient(timeout=30) as client: resp = await client.post(url, headers=headers, json=body) try: data = resp.json() except Exception: data = {"raw": resp.text[:500]} return resp.status_code, data async def main() -> None: p = argparse.ArgumentParser() g = p.add_mutually_exclusive_group(required=True) g.add_argument("--dry-run", action="store_true", help="Show plan, do not apply") g.add_argument("--apply", action="store_true", help="Actually call the skill-sync API") g.add_argument("--verify", action="store_true", help="Show current state only") args = p.parse_args() targets = await fetch_targets() if len(targets) != 4: fail(f"Expected 4 target rows (2 agents × 2 companies), got {len(targets)}") # Build a map for plan by_key = {(r["name"], r["company_id"]): r for r in targets} print(f"\n=== Targets in DB ({len(targets)} rows) ===") for r in targets: company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA" print(f" {r['name']:14s} | {company_label} | id={r['id']} | currently {r['current_skill_count']} skills") print(f"\n=== Plan ===") for (agent_name, company_id), desired in PLAN.items(): company_label = "1xxx CMP" if company_id == CMP_COMPANY_ID else "8xxx CMPA" target = by_key.get((agent_name, company_id)) if not target: print(f" ❌ {agent_name} in {company_label}: NOT FOUND in DB") continue print(f" {agent_name:14s} | {company_label} | will set {len(desired)} skills:") for s in desired: print(f" - {s}") if args.verify: print("\n(verify mode — exiting without changes)") return if args.dry_run: print("\n(dry-run mode — exiting without changes)\nRe-run with --apply to execute.") return # APPLY mode print(f"\n=== Backup ===") backup_path = backup_agents_table() print(f" ✓ Backed up agents table → {backup_path}") print(f"\n=== Applying skill-sync via API ===") failures = [] for (agent_name, company_id), desired in PLAN.items(): target = by_key.get((agent_name, company_id)) if not target: failures.append(f"{agent_name} in {company_id}: not found") continue status, data = await call_skill_sync(target["id"], desired) if status >= 400: failures.append(f"{agent_name} ({company_id[:8]}...): HTTP {status} — {json.dumps(data)[:200]}") print(f" ❌ {agent_name} ({target['id']}): HTTP {status}") else: new_count = len(data.get("desiredSkills") or data.get("skills") or []) print(f" ✓ {agent_name} ({target['id']}): HTTP {status} (now {new_count or len(desired)} skills)") if failures: print(f"\n=== ⚠️ {len(failures)} failures ===") for f in failures: print(f" - {f}") print(f"\nRollback: psql ... -f {backup_path}") sys.exit(1) # Verify print(f"\n=== Post-apply verification ===") final = await fetch_targets() for r in final: company_label = "1xxx CMP" if r["company_id"] == CMP_COMPANY_ID else "8xxx CMPA" emoji = "✓" if r["current_skill_count"] >= 4 else "❌" print(f" {emoji} {r['name']:14s} | {company_label} | now {r['current_skill_count']} skills") if __name__ == "__main__": asyncio.run(main())