Files
atc-agents/api/approval_service.py
mo a11621b21f Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
2026-06-25 00:28:23 +00:00

249 lines
8.8 KiB
Python

"""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