a11621b21f
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
249 lines
8.8 KiB
Python
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
|