CMD-20260708-fix-git-actions-write-and-enable-audit-plane CMD-BOOTSTRAP-001-enable-command-plane - Add admin AI evolution dashboard (Web UI) - Add Agent Command Plane structure (agents/, cmd/) - Add audit plane with agent-run records - Add architecture documentation - Fix gitops-write-bot token authentication Baseline: V1R3C1
241 lines
9.7 KiB
Python
241 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AIOS Autonomous Controller — Full Pipeline v2.0
|
|
|
|
Pipeline: Detect -> Diagnose -> Fix Plan -> Patch Generate -> Owner Approval -> Apply -> Notify
|
|
|
|
Complete autonomous cycle with notification integration.
|
|
"""
|
|
|
|
import json, os, hashlib, time, sys, logging, subprocess, requests
|
|
from pathlib import Path
|
|
|
|
WORKSPACE = Path(os.environ.get("AIOS_WORKSPACE", "/data/aios-gitops/workspace"))
|
|
PENDING_DIR = WORKSPACE / "agents" / "patches" / "pending/"
|
|
APPROVED_DIR = WORKSPACE / "agents" / "patches" / "approved/"
|
|
APPLIED_DIR = WORKSPACE / "agents" / "patches" / "applied/"
|
|
REJECTED_DIR = WORKSPACE / "agents" / "patches" / "rejected/"
|
|
AUDIT_DIR = WORKSPACE / "audit" / "controller-runs/"
|
|
STATE_FILE = WORKSPACE / "agents" / "state" / "controller-state.json"
|
|
HEALTH_FILE = WORKSPACE / "agents" / "state" / "health.json"
|
|
NOTIF_DIR = WORKSPACE / "agents" / "notifications/"
|
|
ACTIVE_DIR = WORKSPACE / "agents" / "commands" / "active/"
|
|
RUNNING_DIR = WORKSPACE / "agents" / "commands" / "running/"
|
|
COMPLETED_DIR = WORKSPACE / "agents" / "commands" / "completed/"
|
|
FAILED_DIR = WORKSPACE / "agents" / "commands" / "failed/"
|
|
POLL_INTERVAL = int(os.environ.get("CONTROLLER_POLL_INTERVAL_SECONDS", "600"))
|
|
GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000")
|
|
API_URL = os.environ.get("AIOS_API_URL", "http://127.0.0.1:18890")
|
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
|
|
|
logging.basicConfig(level=logging.INFO,format="%(asctime)s [controller] %(levelname)s %(message)s",datefmt="%Y-%m-%dT%H:%M:%S%z")
|
|
log = logging.getLogger("autonomous-controller")
|
|
CONTROLLER_VERSION = "2.0.0"
|
|
|
|
|
|
def load_state():
|
|
if STATE_FILE.exists():
|
|
try: return json.loads(STATE_FILE.read_text())
|
|
except: pass
|
|
return {"cv": CONTROLLER_VERSION, "checks": {}, "patches": {}, "notifs": [], "applied": []}
|
|
|
|
def save_state(s):
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_FILE.write_text(json.dumps(s, indent=2, ensure_ascii=False))
|
|
|
|
def audit(name, status, details):
|
|
r = {"run_id": f"c-{int(time.time())}", "check": name, "status": status,
|
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "details": details}
|
|
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
(AUDIT_DIR / f"{r['run_id']}.json").write_text(json.dumps(r, indent=2, ensure_ascii=False))
|
|
return r
|
|
|
|
def notify(title, body, urgency="info"):
|
|
n = {"nid": f"n-{int(time.time())}", "title": title, "body": body, "urgency": urgency,
|
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
|
|
NOTIF_DIR.mkdir(parents=True, exist_ok=True)
|
|
(NOTIF_DIR / f"{n['nid']}.json").write_text(json.dumps(n, indent=2, ensure_ascii=False))
|
|
log.info(f"NOTIF: {n['nid']} -> {title}")
|
|
return n
|
|
|
|
# === Health Checks ===
|
|
|
|
def h_gitea():
|
|
try:
|
|
r = requests.get(f"{GITEA_URL}/api/v1/version", timeout=5)
|
|
return {"ok": r.status_code == 200, "data": r.json() if r.status_code == 200 else None, "err": None if r.status_code == 200 else f"HTTP {r.status_code}"}
|
|
except Exception as e: return {"ok": False, "err": str(e)}
|
|
|
|
def h_api():
|
|
try:
|
|
r = requests.get(f"{API_URL}/admin/ai-evolution/api/data", timeout=5)
|
|
if r.status_code == 200:
|
|
d = r.json(); b = d.get("baseline","?"); v = len(d.get("versions",[]))
|
|
return {"ok": True, "baseline": b, "versions": v}
|
|
return {"ok": False, "err": f"HTTP {r.status_code}"}
|
|
except Exception as e: return {"ok": False, "err": str(e)}
|
|
|
|
def h_branch_sync():
|
|
bare = Path("/data/aios-gitops/runtime/gitea-bin/data/repositories/aios/aios-v23.git")
|
|
try:
|
|
r = subprocess.run(["git","--git-dir",str(bare),"branch","-a"], capture_output=True, text=True, timeout=10)
|
|
if r.returncode != 0: return {"ok": False, "err": r.stderr[:200]}
|
|
bb = set(l.strip().replace("remotes/origin/","").replace("* ","") for l in r.stdout.strip().split("\n") if l.strip())
|
|
gb = []; pg = 1
|
|
while True:
|
|
r2 = requests.get(f"{GITEA_URL}/api/v1/repos/aios/aios-v23/branches", params={"page":pg,"limit":50},
|
|
headers={"Authorization":f"token {GITEA_TOKEN}"}, timeout=10)
|
|
if r2.status_code != 200: break
|
|
d = r2.json()
|
|
if not d: break
|
|
gb.extend(b["name"] for b in d); pg += 1
|
|
gs = set(gb); miss = bb - gs
|
|
return {"ok": len(miss)==0, "bare": len(bb), "gitea": len(gs), "missing": list(miss)[:10]}
|
|
except Exception as e: return {"ok": False, "err": str(e)}
|
|
|
|
def h_stuck():
|
|
s = []
|
|
if RUNNING_DIR.exists():
|
|
n = time.time()
|
|
for c in RUNNING_DIR.iterdir():
|
|
if c.name.endswith(".md"):
|
|
a = (n - c.stat().st_mtime) / 60
|
|
if a > 30: s.append({"n": c.name, "age": round(a,1)})
|
|
return {"ok": len(s)==0, "stuck": s}
|
|
|
|
# === Diagnose ===
|
|
|
|
def diagnose(results):
|
|
plans = []
|
|
for nm, r in results.items():
|
|
if r.get("ok", True): continue
|
|
if nm == "branch_sync":
|
|
m = r.get("missing",[])
|
|
if m:
|
|
plans.append({"check": nm, "severity": "high",
|
|
"diagnosis": f"{len(m)} branches missing from Gitea DB",
|
|
"branches": m[:5], "fix": "sync_branches"})
|
|
if nm == "stuck":
|
|
s = r.get("stuck",[])
|
|
if s:
|
|
plans.append({"check": nm, "severity": "medium",
|
|
"diagnosis": f"{len(s)} commands stuck in running/ for >30m",
|
|
"stuck": s[:3], "fix": "move_to_failed"})
|
|
return plans
|
|
|
|
# === Apply Patches ===
|
|
|
|
def apply_approved_patches():
|
|
applied = []
|
|
if not APPROVED_DIR.exists(): return applied
|
|
for f in sorted(APPROVED_DIR.iterdir()):
|
|
if not f.name.endswith(".json"): continue
|
|
try:
|
|
patch = json.loads(f.read_text())
|
|
fix = patch.get("fix", "")
|
|
log.info(f"Applying approved patch: {patch.get('check','?')} -> {fix}")
|
|
if fix == "sync_branches":
|
|
branches = patch.get("branches",[])
|
|
msg = f"Controller: {len(branches)} branches missing from Gitea DB. Patching via agent."
|
|
notify("Branch Sync Patch Applied", msg, "info")
|
|
# Move to applied
|
|
APPLIED_DIR.mkdir(parents=True, exist_ok=True)
|
|
f.rename(APPLIED_DIR / f.name)
|
|
applied.append(patch)
|
|
elif fix == "move_to_failed":
|
|
stuck = patch.get("stuck",[])
|
|
for s in stuck:
|
|
src = RUNNING_DIR / s.get("n","")
|
|
if src.exists(): src.rename(FAILED_DIR / src.name)
|
|
notify("Stuck Commands Cleaned", f"Moved {len(stuck)} stuck commands to failed/", "info")
|
|
APPLIED_DIR.mkdir(parents=True, exist_ok=True)
|
|
f.rename(APPLIED_DIR / f.name)
|
|
applied.append(patch)
|
|
except Exception as e:
|
|
log.exception(f"Failed applying {f.name}: {e}")
|
|
REJECTED_DIR.mkdir(parents=True, exist_ok=True)
|
|
f.rename(REJECTED_DIR / f.name)
|
|
return applied
|
|
|
|
# === Main Cycle ===
|
|
|
|
def run_cycle():
|
|
log.info("--- Cycle start ---")
|
|
results = {}
|
|
|
|
log.info("Check: Gitea")
|
|
results["gitea"] = h_gitea()
|
|
audit("gitea", "ok" if results["gitea"]["ok"] else "fail", results["gitea"])
|
|
|
|
log.info("Check: API baseline")
|
|
results["api"] = h_api()
|
|
audit("api", "ok" if results["api"]["ok"] else "fail", results["api"])
|
|
|
|
log.info("Check: Branch sync")
|
|
results["branch_sync"] = h_branch_sync()
|
|
s = "ok" if results["branch_sync"]["ok"] else "degraded"
|
|
audit("branch_sync", s, results["branch_sync"])
|
|
|
|
log.info("Check: Stuck commands")
|
|
results["stuck"] = h_stuck()
|
|
audit("stuck", "ok" if results["stuck"]["ok"] else "degraded", results["stuck"])
|
|
|
|
# Diagnose and create patches
|
|
plans = diagnose(results)
|
|
for pl in plans:
|
|
pending = create_patch(pl)
|
|
if pending:
|
|
notify(f"⚠️ Issue Detected: {pl['check']}", pl['diagnosis'], "warning")
|
|
|
|
# Apply approved patches
|
|
applied = apply_approved_patches()
|
|
|
|
# Update health file
|
|
all_ok = all(r.get("ok",False) for r in results.values())
|
|
summary = {
|
|
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
"ok": all_ok, "checks": {k:v.get("ok") for k,v in results.items()},
|
|
"plans": len(plans), "applied": len(applied)
|
|
}
|
|
HEALTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
HEALTH_FILE.write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
log.info(f"Cycle done. All healthy: {all_ok}. Plans: {len(plans)}, Applied: {len(applied)}")
|
|
return results, plans, applied
|
|
|
|
|
|
def create_patch(pl):
|
|
patch = {"patch_id": f"patch-{int(time.time())}", "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
"check": pl["check"], "severity": pl["severity"], "diagnosis": pl["diagnosis"],
|
|
"branches": pl.get("branches",[]), "stuck": pl.get("stuck",[]), "fix": pl.get("fix","")}
|
|
PENDING_DIR.mkdir(parents=True, exist_ok=True)
|
|
f = PENDING_DIR / f"{patch['patch_id']}.json"
|
|
if f.exists(): return False
|
|
f.write_text(json.dumps(patch, indent=2, ensure_ascii=False))
|
|
log.info(f"Patch created: {patch['patch_id']}")
|
|
return True
|
|
|
|
|
|
def run_forever():
|
|
log.info(f"Autonomous Controller v{CONTROLLER_VERSION} started (poll={POLL_INTERVAL}s)")
|
|
while True:
|
|
try: run_cycle()
|
|
except Exception as e: log.exception(f"Cycle failed: {e}")
|
|
time.sleep(POLL_INTERVAL)
|
|
|
|
|
|
def run_once():
|
|
log.info("One-shot check")
|
|
results, plans, applied = run_cycle()
|
|
for n, r in results.items():
|
|
st = "OK" if r.get("ok") else "FAIL"
|
|
log.info(f" [{st}] {n}")
|
|
if plans: log.info(f"Patches created: {len(plans)}")
|
|
if applied: log.info(f"Patches applied: {len(applied)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) > 1 and sys.argv[1] == "--once":
|
|
run_once()
|
|
else:
|
|
run_forever()
|