agentic-ai-engineering/skill_health.py

131 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""RSI cron + ZTE auto-patch. Detects degraded skills, snapshots, issues signed /deploy actions, rolls back on failure."""
import hashlib
import hmac
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from datetime import datetime
SKILLS_DIR = Path(os.environ.get("HERMES_HOME", "/opt/data")) / "skills"
SNAPSHOT_DIR = Path("/tmp/rsi-snapshots")
DEPLOY_URL = os.environ.get("DEPLOY_URL", "http://localhost:8099/deploy")
DEPLOY_TOKEN = os.environ.get("DEPLOY_TOKEN", "factory-deploy-token-2026")
FAIL_THRESHOLD = 0.8
def run(cmd, timeout=30):
return subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True, text=True, timeout=timeout)
def signed_payload(action, target, actor="skill_health", payload_hash="none"):
data = {
"timestamp": int(time.time()),
"nonce": hashlib.sha256(f"{time.time()}:{target}:{action}".encode()).hexdigest()[:16],
"actor": actor,
"action": action,
"target": target,
"payload_hash": payload_hash,
}
body = json.dumps({k: data[k] for k in ("timestamp", "nonce", "actor", "action", "target", "payload_hash")}, sort_keys=True, separators=(",", ":")).encode()
data["signature"] = hmac.new(DEPLOY_TOKEN.encode(), body, hashlib.sha256).hexdigest()
return data
def post_deploy(payload, timeout=15):
req = urllib.request.Request(
DEPLOY_URL,
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {DEPLOY_TOKEN}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read().decode()
except urllib.error.HTTPError as e:
return e.read().decode()
def check_skill(name):
d = SKILLS_DIR / name
t = d / "test.sh"
if not t.exists():
return None
r = run(["bash", str(t)])
if r.returncode == 0:
Path("/opt/data/rsi-known-good").mkdir(exist_ok=True)
(Path("/opt/data/rsi-known-good") / f"{name}.sig").write_text(hashlib.sha256(open(t, "rb").read()).hexdigest()[:16])
return {"skill": name, "passed": r.returncode == 0}
def snapshot():
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
tag = datetime.now().strftime("%Y%m%d_%H%M%S")
shutil.copytree(SKILLS_DIR, SNAPSHOT_DIR / tag, dirs_exist_ok=True)
(SNAPSHOT_DIR / "latest").write_text(tag)
return tag
def rollback():
lf = SNAPSHOT_DIR / "latest"
if not lf.exists():
return False
snap = SNAPSHOT_DIR / lf.read_text().strip()
if not snap.exists():
return False
shutil.rmtree(SKILLS_DIR)
shutil.copytree(snap, SKILLS_DIR, dirs_exist_ok=True)
return True
def auto_patch(failed):
"""ZTE: snapshot, request safe skill action, verify, rollback if still failing."""
print(f"🔄 ZTE: auto-patching {len(failed)} degraded skills")
snap = snapshot()
print(f"📸 Snap: {snap}")
for f in failed:
skill = f["skill"]
print(f" Fixing {skill}...")
payload_hash = hashlib.sha256(f"{snap}:{skill}".encode()).hexdigest()[:16]
result = post_deploy(signed_payload("patch_skill_from_pr", skill, payload_hash=payload_hash))
print(f" Result: {result[:100]}")
verify = [check_skill(f["skill"]) for f in failed]
still_failing = [v for v in verify if v and not v["passed"]]
if still_failing:
print(f"⚠️ {len(still_failing)} still failing, rolling back...")
rollback()
print("✅ Rolled back")
else:
print("✅ All patched successfully")
def main():
do_patch = "--auto-patch" in sys.argv
results = [check_skill(d.name) for d in sorted(SKILLS_DIR.iterdir()) if d.is_dir() and (d / "test.sh").exists()]
results = [r for r in results if r]
passed = sum(1 for r in results if r["passed"])
total = len(results)
score = round(passed / total, 2) if total else 0
failed = [r for r in results if not r["passed"]]
for r in results:
print(f"{'' if r['passed'] else ''} {r['skill']}")
print(f"\nRSI: {passed}/{total} ({score:.0%})")
if failed:
print(f"Degraded: {[f['skill'] for f in failed]}")
if do_patch:
auto_patch(failed)
else:
print("Reported (no auto-patch)")
else:
print("All healthy — ZTE idle")
if __name__ == "__main__":
main()