2026-06-09 00:41:27 +00:00
|
|
|
"""Agents API — souls & activity."""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
import asyncio
|
|
|
|
|
import json
|
2026-06-09 00:41:27 +00:00
|
|
|
from typing import Any, Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
2026-06-23 10:04:23 +00:00
|
|
|
from fastapi.responses import StreamingResponse
|
2026-06-09 00:41:27 +00:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
from app.db import fetch_all, fetch_one
|
|
|
|
|
from app.services import agent_approvals, agent_integration, agent_souls
|
|
|
|
|
from app.services.agent_names import normalize_agent_key
|
|
|
|
|
from app.services.agent_terminal import execute_terminal_command
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/agents", tags=["agents-api"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SoulUpdate(BaseModel):
|
|
|
|
|
display_name: Optional[str] = None
|
|
|
|
|
role_title: Optional[str] = None
|
|
|
|
|
soul_md: Optional[str] = None
|
|
|
|
|
responsibilities: Optional[str] = None
|
|
|
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 10:41:13 +00:00
|
|
|
class ActionRequestBody(BaseModel):
|
|
|
|
|
agent_key: str
|
|
|
|
|
title: str = Field(..., min_length=1, max_length=255)
|
|
|
|
|
action_type: str = "query"
|
|
|
|
|
query_payload: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RejectBody(BaseModel):
|
|
|
|
|
reason: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExecuteBody(BaseModel):
|
|
|
|
|
result: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
class HandoffBody(BaseModel):
|
|
|
|
|
from_agent: str
|
|
|
|
|
to_agent: str
|
|
|
|
|
handoff_type: str = "partner"
|
|
|
|
|
payload: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
correlation_id: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TerminalCommandBody(BaseModel):
|
|
|
|
|
command: str = Field(..., min_length=1, max_length=2000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _aggregate_agent_stats() -> dict[str, dict[str, Any]]:
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT agent_name, title, status, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE created_at >= NOW() - INTERVAL '7 days'
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
by_key: dict[str, dict[str, Any]] = {}
|
|
|
|
|
for r in rows:
|
|
|
|
|
key = normalize_agent_key(str(r.get("agent_name") or ""))
|
|
|
|
|
if not key:
|
|
|
|
|
continue
|
|
|
|
|
bucket = by_key.setdefault(
|
|
|
|
|
key,
|
|
|
|
|
{"events_6h": 0, "errors_24h": 0, "last_event_at": None, "last_event_title": None},
|
|
|
|
|
)
|
|
|
|
|
created = r.get("created_at")
|
|
|
|
|
if bucket["last_event_at"] is None and created is not None:
|
|
|
|
|
bucket["last_event_at"] = created
|
|
|
|
|
bucket["last_event_title"] = r.get("title")
|
|
|
|
|
if created is not None:
|
|
|
|
|
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
|
|
|
|
created = created.replace(tzinfo=timezone.utc)
|
|
|
|
|
if created >= now - timedelta(hours=6):
|
|
|
|
|
bucket["events_6h"] += 1
|
|
|
|
|
if created >= now - timedelta(hours=24) and str(r.get("status") or "") in ("error", "rejected"):
|
|
|
|
|
bucket["errors_24h"] += 1
|
|
|
|
|
return by_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _node_health(events_6h: int, errors_24h: int, event_count: int) -> str:
|
|
|
|
|
if events_6h > 0 and errors_24h == 0:
|
|
|
|
|
return "healthy"
|
|
|
|
|
if events_6h > 0:
|
|
|
|
|
return "warn"
|
|
|
|
|
if event_count > 0:
|
|
|
|
|
return "idle"
|
|
|
|
|
return "offline"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/collaboration")
|
|
|
|
|
def api_collaboration() -> dict[str, Any]:
|
|
|
|
|
items = agent_integration.list_collaboration()
|
|
|
|
|
return {"items": items, "count": len(items)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/handoff")
|
|
|
|
|
def api_create_handoff(body: HandoffBody) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
handoff = agent_integration.create_handoff(
|
|
|
|
|
body.from_agent,
|
|
|
|
|
body.to_agent,
|
|
|
|
|
handoff_type=body.handoff_type,
|
|
|
|
|
payload=body.payload,
|
|
|
|
|
correlation_id=body.correlation_id,
|
|
|
|
|
)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(400, str(exc)) from exc
|
|
|
|
|
return {"ok": True, "handoff": handoff}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _terminal_payload(ev: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
if ev.get("created_at") and hasattr(ev["created_at"], "isoformat"):
|
|
|
|
|
ev["created_at"] = ev["created_at"].isoformat()
|
|
|
|
|
meta = ev.get("metadata")
|
|
|
|
|
if isinstance(meta, str):
|
|
|
|
|
try:
|
|
|
|
|
meta = json.loads(meta)
|
|
|
|
|
except Exception:
|
|
|
|
|
meta = {}
|
|
|
|
|
ev["metadata"] = meta or {}
|
|
|
|
|
line_type = "handoff" if "handoff" in str(ev.get("event_type") or "") else "action"
|
|
|
|
|
if str(ev.get("event_type") or "").startswith("terminal_"):
|
|
|
|
|
line_type = "command" if ev.get("event_type") == "terminal_in" else "output"
|
|
|
|
|
if ev["metadata"].get("target_agent"):
|
|
|
|
|
line_type = "handoff_out"
|
|
|
|
|
if ev["metadata"].get("source_agent"):
|
|
|
|
|
line_type = "handoff_in"
|
|
|
|
|
if str(ev.get("status") or "") in ("error", "rejected"):
|
|
|
|
|
line_type = "error"
|
|
|
|
|
return {
|
|
|
|
|
"type": line_type,
|
|
|
|
|
"id": ev.get("id"),
|
|
|
|
|
"agent": normalize_agent_key(ev.get("agent_name")),
|
|
|
|
|
"message": ev.get("title") or ev.get("event_type"),
|
|
|
|
|
"detail": (ev.get("body") or "")[:500],
|
|
|
|
|
"status": ev.get("status"),
|
|
|
|
|
"correlation_id": ev["metadata"].get("correlation_id"),
|
|
|
|
|
"at": ev.get("created_at"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/souls/{agent_key}/terminal/stream")
|
|
|
|
|
async def api_agent_terminal_stream(agent_key: str, correlation_id: Optional[str] = None):
|
|
|
|
|
key = normalize_agent_key(agent_key)
|
|
|
|
|
soul = agent_souls.get_soul(key)
|
|
|
|
|
if not soul:
|
|
|
|
|
raise HTTPException(404, "Agent not found")
|
|
|
|
|
|
|
|
|
|
async def event_gen():
|
|
|
|
|
last_id = 0
|
|
|
|
|
try:
|
|
|
|
|
hist = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE LOWER(agent_name) = %s
|
|
|
|
|
ORDER BY id DESC
|
|
|
|
|
LIMIT 30
|
|
|
|
|
""",
|
|
|
|
|
(key,),
|
|
|
|
|
)
|
|
|
|
|
for row in reversed(hist):
|
|
|
|
|
last_id = max(last_id, int(row["id"]))
|
|
|
|
|
payload = _terminal_payload(dict(row))
|
|
|
|
|
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
if correlation_id:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE metadata->>'correlation_id' = %s
|
|
|
|
|
AND id > %s
|
|
|
|
|
ORDER BY id ASC
|
|
|
|
|
LIMIT 50
|
|
|
|
|
""",
|
|
|
|
|
(correlation_id, last_id),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE LOWER(agent_name) = %s AND id > %s
|
|
|
|
|
ORDER BY id ASC
|
|
|
|
|
LIMIT 50
|
|
|
|
|
""",
|
|
|
|
|
(key, last_id),
|
|
|
|
|
)
|
|
|
|
|
for row in rows:
|
|
|
|
|
last_id = max(last_id, int(row["id"]))
|
|
|
|
|
payload = _terminal_payload(dict(row))
|
|
|
|
|
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
err = {"type": "error", "message": str(exc)}
|
|
|
|
|
yield f"data: {json.dumps(err)}\n\n"
|
|
|
|
|
await asyncio.sleep(0.4)
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
event_gen(),
|
|
|
|
|
media_type="text/event-stream",
|
|
|
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/live/stream")
|
|
|
|
|
async def api_agents_live_stream():
|
|
|
|
|
"""Single realtime stream for all agent terminals."""
|
|
|
|
|
|
|
|
|
|
async def event_gen():
|
|
|
|
|
last_id = 0
|
|
|
|
|
try:
|
|
|
|
|
recent = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE created_at >= NOW() - INTERVAL '30 minutes'
|
|
|
|
|
ORDER BY id ASC
|
|
|
|
|
LIMIT 80
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
for row in recent:
|
|
|
|
|
last_id = max(last_id, int(row["id"]))
|
|
|
|
|
payload = _terminal_payload(dict(row))
|
|
|
|
|
payload["replay"] = True
|
|
|
|
|
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
yield f"data: {json.dumps({'type': 'connected', 'last_id': last_id})}\n\n"
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
try:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at
|
|
|
|
|
FROM agent_events
|
|
|
|
|
WHERE id > %s
|
|
|
|
|
ORDER BY id ASC
|
|
|
|
|
LIMIT 100
|
|
|
|
|
""",
|
|
|
|
|
(last_id,),
|
|
|
|
|
)
|
|
|
|
|
for row in rows:
|
|
|
|
|
last_id = max(last_id, int(row["id"]))
|
|
|
|
|
payload = _terminal_payload(dict(row))
|
|
|
|
|
yield f"data: {json.dumps(payload, default=str)}\n\n"
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
err = {"type": "error", "message": str(exc)}
|
|
|
|
|
yield f"data: {json.dumps(err)}\n\n"
|
|
|
|
|
await asyncio.sleep(0.25)
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
event_gen(),
|
|
|
|
|
media_type="text/event-stream",
|
|
|
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
@router.get("/souls")
|
|
|
|
|
def api_list_souls() -> dict[str, Any]:
|
|
|
|
|
return {"items": agent_souls.list_souls(), "count": len(agent_souls.list_souls())}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/souls/{agent_key}")
|
|
|
|
|
def api_get_soul(agent_key: str) -> dict[str, Any]:
|
|
|
|
|
soul = agent_souls.get_soul(agent_key)
|
|
|
|
|
if not soul:
|
|
|
|
|
raise HTTPException(404, "Agent not found")
|
|
|
|
|
return {"soul": soul}
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 10:41:13 +00:00
|
|
|
@router.get("/souls/{agent_key}/events")
|
|
|
|
|
def api_list_agent_events(agent_key: str, limit: int = 50) -> dict[str, Any]:
|
|
|
|
|
soul = agent_souls.get_soul(agent_key)
|
|
|
|
|
if not soul:
|
|
|
|
|
raise HTTPException(404, "Agent not found")
|
|
|
|
|
items = agent_souls.list_agent_events(agent_key, limit=min(limit, 100))
|
|
|
|
|
return {"agent_key": agent_key.lower(), "items": items, "count": len(items)}
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
@router.post("/souls/{agent_key}/terminal/command")
|
|
|
|
|
async def api_terminal_command(agent_key: str, body: TerminalCommandBody) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
return await execute_terminal_command(agent_key, body.command.strip())
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
@router.put("/souls/{agent_key}")
|
|
|
|
|
def api_update_soul(agent_key: str, body: SoulUpdate) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
soul = agent_souls.update_soul(agent_key, **body.model_dump(exclude_none=True))
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(404, str(exc)) from exc
|
|
|
|
|
return {"soul": soul}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/mesh")
|
|
|
|
|
def api_agents_mesh() -> dict[str, Any]:
|
|
|
|
|
souls = agent_souls.list_souls()
|
2026-06-23 10:04:23 +00:00
|
|
|
by_key = _aggregate_agent_stats()
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
nodes: list[dict[str, Any]] = []
|
|
|
|
|
for soul in souls:
|
|
|
|
|
key = str(soul.get("agent_key") or "").lower()
|
|
|
|
|
row = by_key.get(key, {})
|
|
|
|
|
events_6h = int(row.get("events_6h") or 0)
|
|
|
|
|
errors_24h = int(row.get("errors_24h") or 0)
|
2026-06-23 10:04:23 +00:00
|
|
|
health = _node_health(events_6h, errors_24h, int(soul.get("event_count") or 0))
|
|
|
|
|
is_active = health == "healthy" and events_6h > 0
|
2026-06-09 00:41:27 +00:00
|
|
|
node = dict(soul)
|
|
|
|
|
node["health"] = health
|
|
|
|
|
node["events_6h"] = events_6h
|
|
|
|
|
node["errors_24h"] = errors_24h
|
2026-06-23 10:04:23 +00:00
|
|
|
node["is_active"] = is_active
|
|
|
|
|
node["last_event_title"] = row.get("last_event_title")
|
|
|
|
|
lat = row.get("last_event_at")
|
|
|
|
|
if lat is not None and hasattr(lat, "isoformat"):
|
|
|
|
|
node["last_event_at"] = lat.isoformat()
|
2026-06-09 00:41:27 +00:00
|
|
|
nodes.append(node)
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
report_edges: list[dict[str, Any]] = []
|
|
|
|
|
for node in nodes:
|
|
|
|
|
key = str(node.get("agent_key") or "").lower()
|
|
|
|
|
if key == "herman" or not node.get("is_active"):
|
|
|
|
|
continue
|
|
|
|
|
report_edges.append(
|
|
|
|
|
{
|
|
|
|
|
"source": key,
|
|
|
|
|
"target": "herman",
|
|
|
|
|
"type": "report",
|
|
|
|
|
"active": True,
|
|
|
|
|
"weight": int(node.get("events_6h") or 1),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
delegate_rows = fetch_all(
|
2026-06-09 00:41:27 +00:00
|
|
|
"""
|
2026-06-23 10:04:23 +00:00
|
|
|
SELECT metadata, created_at
|
2026-06-09 00:41:27 +00:00
|
|
|
FROM agent_events
|
2026-06-23 10:04:23 +00:00
|
|
|
WHERE agent_name = 'herman'
|
|
|
|
|
AND created_at >= NOW() - INTERVAL '6 hours'
|
|
|
|
|
AND (
|
|
|
|
|
event_type IN ('openswarm_delegation', 'delegate', 'packaging_delivered', 'telegram_delegation')
|
|
|
|
|
OR metadata ? 'delegated'
|
|
|
|
|
)
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT 100
|
2026-06-09 00:41:27 +00:00
|
|
|
"""
|
|
|
|
|
)
|
2026-06-23 10:04:23 +00:00
|
|
|
delegate_edges: list[dict[str, Any]] = []
|
|
|
|
|
seen_delegate: set[tuple[str, str]] = set()
|
|
|
|
|
for r in delegate_rows:
|
|
|
|
|
meta = r.get("metadata") or {}
|
|
|
|
|
if isinstance(meta, str):
|
|
|
|
|
try:
|
|
|
|
|
meta = json.loads(meta)
|
|
|
|
|
except Exception:
|
|
|
|
|
meta = {}
|
|
|
|
|
delegated = meta.get("delegated") or meta.get("delegated_agents") or []
|
|
|
|
|
if isinstance(delegated, str):
|
|
|
|
|
delegated = [delegated]
|
|
|
|
|
channel = meta.get("channel") or "herman"
|
|
|
|
|
for agent in delegated:
|
|
|
|
|
tgt = normalize_agent_key(str(agent))
|
|
|
|
|
if not tgt or tgt == "herman":
|
|
|
|
|
continue
|
|
|
|
|
pair = ("herman", tgt)
|
|
|
|
|
if pair in seen_delegate:
|
|
|
|
|
continue
|
|
|
|
|
seen_delegate.add(pair)
|
|
|
|
|
delegate_edges.append(
|
|
|
|
|
{
|
|
|
|
|
"source": "herman",
|
|
|
|
|
"target": tgt,
|
|
|
|
|
"type": "delegate",
|
|
|
|
|
"active": True,
|
|
|
|
|
"channel": channel,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
peer_static = [
|
|
|
|
|
{
|
|
|
|
|
"source": str(r["from_agent"]),
|
|
|
|
|
"target": str(r["to_agent"]),
|
|
|
|
|
"type": "static",
|
|
|
|
|
"handoff_type": r.get("handoff_type"),
|
|
|
|
|
}
|
|
|
|
|
for r in agent_integration.list_collaboration()
|
|
|
|
|
]
|
|
|
|
|
peer_live = agent_integration.peer_edges_live(hours=6)
|
|
|
|
|
peer_edges = peer_static + peer_live
|
|
|
|
|
|
|
|
|
|
herman_active = any(n.get("agent_key") == "herman" and n.get("is_active") for n in nodes)
|
|
|
|
|
herman_active = herman_active or bool(delegate_edges) or bool(report_edges)
|
|
|
|
|
executive_edges = []
|
|
|
|
|
if herman_active:
|
|
|
|
|
executive_edges = [
|
|
|
|
|
{"source": "herman", "target": "ceo", "type": "executive", "active": True},
|
|
|
|
|
{"source": "herman", "target": "cto", "type": "executive", "active": True},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"nodes": nodes,
|
|
|
|
|
"report_edges": report_edges,
|
|
|
|
|
"delegate_edges": delegate_edges,
|
|
|
|
|
"peer_edges": peer_edges,
|
|
|
|
|
"executive_edges": executive_edges,
|
|
|
|
|
"edges": report_edges,
|
|
|
|
|
"executives": [
|
|
|
|
|
{"id": "ceo", "label": "CEO", "role": "Aissa"},
|
|
|
|
|
{"id": "cto", "label": "CTO", "role": "Platform"},
|
|
|
|
|
],
|
|
|
|
|
}
|
2026-06-09 10:41:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/approvals")
|
|
|
|
|
def api_list_approvals(status: Optional[str] = None, limit: int = 50) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
items = agent_approvals.list_requests(status=status, limit=limit)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
|
|
|
return {"items": items, "count": len(items)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/requests")
|
|
|
|
|
def api_create_action_request(body: ActionRequestBody) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
req = agent_approvals.create_request(
|
|
|
|
|
body.agent_key,
|
|
|
|
|
body.title,
|
|
|
|
|
action_type=body.action_type,
|
|
|
|
|
query_payload=body.query_payload,
|
|
|
|
|
)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
2026-06-23 10:04:23 +00:00
|
|
|
auto = None
|
|
|
|
|
rid = (req or {}).get("id")
|
|
|
|
|
if rid:
|
|
|
|
|
try:
|
|
|
|
|
auto = agent_approvals.try_auto_approve_and_execute(int(rid))
|
|
|
|
|
except Exception:
|
|
|
|
|
auto = None
|
|
|
|
|
msg = "Wacht op goedkeuring voordat query uitgevoerd mag worden"
|
|
|
|
|
if auto:
|
|
|
|
|
msg = "Auto-goedgekeurd en uitgevoerd (SysOps policy)"
|
|
|
|
|
return {"ok": True, "request": req, "auto_executed": bool(auto), "message": msg}
|
2026-06-09 10:41:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/approvals/{request_id}/approve")
|
|
|
|
|
def api_approve_request(request_id: int) -> dict[str, Any]:
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
req = agent_approvals.approve_request(request_id, approved_by="ceo")
|
|
|
|
|
action = req.get("action_type") or ""
|
|
|
|
|
auto_result = None
|
|
|
|
|
|
|
|
|
|
if action in ("config_backup", "maintenance_scan"):
|
|
|
|
|
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
|
|
|
|
path = "/ops/backup/run" if action == "config_backup" else "/ops/maintenance/scan"
|
|
|
|
|
payload = {"approval_request_id": request_id} if action == "config_backup" else None
|
|
|
|
|
with httpx.Client(timeout=180.0) as client:
|
|
|
|
|
resp = client.post(f"{tools_url}{path}", json=payload or {})
|
|
|
|
|
try:
|
|
|
|
|
auto_result = resp.json()
|
|
|
|
|
except Exception:
|
|
|
|
|
auto_result = {"ok": False, "detail": resp.text}
|
|
|
|
|
req = agent_approvals.mark_executed(request_id, auto_result or {})
|
|
|
|
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
|
|
|
return {"ok": True, "request": req, "executed": auto_result is not None, "result": auto_result}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/approvals/{request_id}/reject")
|
|
|
|
|
def api_reject_request(request_id: int, body: RejectBody) -> dict[str, Any]:
|
|
|
|
|
try:
|
|
|
|
|
req = agent_approvals.reject_request(request_id, reason=body.reason)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
return {"ok": True, "request": req}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/approvals/{request_id}/execute")
|
|
|
|
|
def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str, Any]:
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
req = agent_approvals.require_approved(request_id)
|
|
|
|
|
result_payload = body.result or {}
|
|
|
|
|
action = req.get("action_type") or ""
|
|
|
|
|
|
|
|
|
|
if action == "config_backup":
|
|
|
|
|
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
|
|
|
|
with httpx.Client(timeout=180.0) as client:
|
|
|
|
|
resp = client.post(
|
|
|
|
|
f"{tools_url}/ops/backup/run",
|
|
|
|
|
json={"approval_request_id": request_id},
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
result_payload = resp.json()
|
|
|
|
|
except Exception:
|
|
|
|
|
result_payload = {"ok": False, "detail": resp.text}
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
raise HTTPException(status_code=502, detail=result_payload.get("detail") or resp.text)
|
|
|
|
|
|
|
|
|
|
elif action == "maintenance_scan":
|
|
|
|
|
tools_url = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
|
|
|
|
|
with httpx.Client(timeout=120.0) as client:
|
|
|
|
|
resp = client.post(f"{tools_url}/ops/maintenance/scan")
|
|
|
|
|
result_payload = resp.json() if resp.status_code < 400 else {"ok": False, "detail": resp.text}
|
|
|
|
|
|
|
|
|
|
req = agent_approvals.mark_executed(request_id, result_payload)
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except PermissionError as exc:
|
|
|
|
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
|
|
|
return {"ok": True, "request": req}
|