"""Approval workflow — agents request; Mo & Bart approve before mutating actions.""" from __future__ import annotations import json import re from datetime import datetime, timezone from typing import Any, Awaitable, Callable from sqlalchemy import select from sqlalchemy.orm import Session APPROVAL_ACTION_TYPES = { "docker.restart": "Docker container restart", "docker.update": "Docker image update / pull", "docker.recreate": "Docker container recreate", "docker.stop": "Docker container stop", "docker.remove": "Docker container remove", "docker.compose": "Docker Compose deploy", "docker.prune": "Docker prune / cleanup", "db.migrate": "Database schema migration", "db.restart": "Database service restart", "etl.restart": "ETL / connector restart", "kafka.reset": "Kafka topic / offset reset", "hdfs.mutate": "HDFS destructive operation", "infra.reboot": "VM / host reboot", "generic.mutate": "Infrastructure change", } SUPERVISOR_IDS = ["mo-commander", "bart-commander"] _INTENT_PATTERNS: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"\b(restart|herstart|reboot)\b.*\b(container|docker|stack|service|mysql|postgres|kafka|airflow)"), "docker.restart"), (re.compile(r"\b(update|upgrade|updaten|pull|pullen)\b.*\b(docker|image|container|stack|compose)"), "docker.update"), (re.compile(r"\b(recreate|rebuild|redeploy|deploy|opnieuw)\b.*\b(container|docker|stack|compose|service)"), "docker.recreate"), (re.compile(r"\b(stop|stoppen|shutdown)\b.*\b(container|docker|service)"), "docker.stop"), (re.compile(r"\b(remove|delete|verwijder|rm|prune|opschonen)\b.*\b(container|docker|image|volume)"), "docker.remove"), (re.compile(r"\b(docker compose|compose up|stack deploy)"), "docker.compose"), (re.compile(r"\b(migrate|migration|schema change)\b.*\b(db|database|postgres|mysql)"), "db.migrate"), (re.compile(r"\b(restart|herstart)\b.*\b(db|database|postgres|mysql|mongo|cassandra)"), "db.restart"), (re.compile(r"\b(restart|reset)\b.*\b(connector|debezium|kafka connect)"), "etl.restart"), (re.compile(r"\b(reset|truncate|drop)\b.*\b(topic|kafka|offset)"), "kafka.reset"), (re.compile(r"\b(reboot|restart)\b.*\b(vm|host|server|node|proxmox)"), "infra.reboot"), ] _RESPONSE_ACTION_PATTERN = re.compile( r"\b(will|ga|moet|plan to|going to|propose|voorstel)\b.*\b(restart|update|pull|recreate|deploy|stop|remove|reboot|migrate)", re.I, ) def detect_approval_intent(text: str) -> dict[str, Any] | None: lower = text.lower().strip() for pattern, action_type in _INTENT_PATTERNS: if pattern.search(lower): return { "action_type": action_type, "action": text.strip()[:500], "reason": f"Mutating operation detected ({APPROVAL_ACTION_TYPES.get(action_type, action_type)})", } return None def detect_agent_proposed_action(llm_answer: str, original_message: str) -> dict[str, Any] | None: if not _RESPONSE_ACTION_PATTERN.search(llm_answer): return None intent = detect_approval_intent(llm_answer) or detect_approval_intent(original_message) if intent: intent["reason"] = f"Agent proposed action in mission response: {intent['reason']}" intent["action"] = llm_answer.strip()[:500] return intent def approval_to_dict(row: Any) -> dict[str, Any]: payload: dict[str, Any] = {} raw_payload = getattr(row, "payload", None) if raw_payload: try: payload = json.loads(raw_payload) except (json.JSONDecodeError, TypeError): payload = {"raw": raw_payload} decided_at = getattr(row, "decided_at", None) return { "id": row.id, "ts": row.ts.isoformat() if row.ts else None, "agent_id": row.agent_id, "action": row.action, "reason": row.reason, "status": row.status, "action_type": getattr(row, "action_type", None) or "generic.mutate", "target": getattr(row, "target", None) or "", "payload": payload, "decided_by": getattr(row, "decided_by", None), "decide_note": getattr(row, "decide_note", None), "decided_at": decided_at.isoformat() if decided_at else None, "priority": getattr(row, "priority", None) or "normal", } def migrate_approval_columns(engine: Any) -> None: """Legacy shim — migrations live in db.py.""" from db import migrate_approval_columns as _migrate _migrate(engine) async def create_approval_request( *, db: Session, ApprovalModel: type, agent_id: str, action: str, reason: str, action_type: str = "generic.mutate", target: str = "", payload: dict | None = None, priority: str = "normal", terminal_log: Callable[..., Awaitable[None]] | None = None, mirror_supervisors: Callable[..., Awaitable[None]] | None = None, publish: Callable[[dict], Awaitable[None]] | None = None, add_feed: Callable[[str, str, str], dict] | None = None, ) -> dict[str, Any]: import uuid approval_id = str(uuid.uuid4())[:10] now = datetime.now(timezone.utc) row = ApprovalModel( id=approval_id, ts=now, agent_id=agent_id, action=action, reason=reason, status="pending", action_type=action_type, target=target, payload=json.dumps(payload or {}), priority=priority, ) db.add(row) db.commit() db.refresh(row) item = approval_to_dict(row) type_label = APPROVAL_ACTION_TYPES.get(action_type, action_type) alert = ( f"⚠ APPROVAL REQUIRED · {type_label}\n" f" Agent: {agent_id}\n" f" Action: {action[:200]}\n" f" Target: {target or '—'}\n" f" Reason: {reason[:200]}\n" f" ID: {approval_id} — awaiting Mo & Bart" ) if terminal_log: for sid in SUPERVISOR_IDS: await terminal_log(sid, alert, level="warn", phase="approval") await terminal_log( agent_id, f"⏸ Action queued for approval ({approval_id}) — Mo & Bart notified", level="warn", phase="approval", ) if mirror_supervisors: await mirror_supervisors( agent_id, f"APPROVAL REQUEST [{approval_id}] {action_type}: {action[:120]}", level="warn", phase="approval", ) if add_feed and publish: feed = add_feed( agent_id, f"Approval requested ({approval_id}): {action[:80]} — waiting for Mo & Bart", "warn", ) await publish({"type": "feed", "entry": feed}) if publish: await publish({"type": "approval_new", "approval": item}) return item async def decide_approval_request( *, db: Session, ApprovalModel: type, approval_id: str, approved: bool, decided_by: str = "mo-commander", note: str = "", terminal_log: Callable[..., Awaitable[None]] | None = None, publish: Callable[[dict], Awaitable[None]] | None = None, add_feed: Callable[[str, str, str], dict] | None = None, ) -> dict[str, Any] | None: row = db.get(ApprovalModel, approval_id) if not row: return None if row.status != "pending": return approval_to_dict(row) row.status = "approved" if approved else "denied" row.decided_by = decided_by row.decide_note = note or None row.decided_at = datetime.now(timezone.utc) db.commit() db.refresh(row) item = approval_to_dict(row) verb = "APPROVED" if approved else "DENIED" who = "Mo" if "mo" in decided_by else "Bart" if "bart" in decided_by else decided_by msg = f"{verb} by {who}: {row.action[:100]}" if note: msg += f" — {note[:80]}" if terminal_log: await terminal_log(row.agent_id, msg, level="ok" if approved else "warn", phase="approval") for sid in SUPERVISOR_IDS: await terminal_log(sid, f"✓ {msg}", level="ok" if approved else "info", phase="approval") if add_feed and publish: feed = add_feed(row.agent_id, msg, "info" if approved else "warn") await publish({"type": "feed", "entry": feed}) if publish: await publish({"type": "approval_update", "approval": item}) return item def list_approvals(db: Session, ApprovalModel: type, status: str = "pending", limit: int = 100) -> list[dict[str, Any]]: q = select(ApprovalModel).order_by(ApprovalModel.ts.desc()).limit(limit) if status and status != "all": q = q.where(ApprovalModel.status == status) rows = db.execute(q).scalars().all() return [approval_to_dict(r) for r in rows] def approval_stats(db: Session, ApprovalModel: type) -> dict[str, int]: rows = db.execute(select(ApprovalModel)).scalars().all() stats = {"pending": 0, "approved": 0, "denied": 0, "total": 0} for r in rows: stats["total"] += 1 if r.status in stats: stats[r.status] += 1 return stats