194 lines
6.0 KiB
Python
194 lines
6.0 KiB
Python
"""Agent action approval queue — gate before executing sensitive queries."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any, Optional
|
|
|
|
from app.db import execute, fetch_all, fetch_one
|
|
|
|
|
|
def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if not row:
|
|
return None
|
|
out = dict(row)
|
|
for key, val in list(out.items()):
|
|
if hasattr(val, "isoformat"):
|
|
out[key] = val.isoformat()
|
|
return out
|
|
|
|
|
|
def has_pending_request(agent_key: str, action_type: str | None = None) -> bool:
|
|
clauses = ["agent_key = %s", "status = 'pending'", "created_at >= CURRENT_DATE"]
|
|
params: list[Any] = [agent_key.strip().lower()]
|
|
if action_type:
|
|
clauses.append("action_type = %s")
|
|
params.append(action_type)
|
|
row = fetch_one(
|
|
f"SELECT id FROM agent_action_requests WHERE {' AND '.join(clauses)} LIMIT 1",
|
|
tuple(params),
|
|
)
|
|
return bool(row)
|
|
|
|
|
|
def create_request(
|
|
agent_key: str,
|
|
title: str,
|
|
action_type: str = "query",
|
|
query_payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
key = (agent_key or "").strip().lower()
|
|
if not key or not title.strip():
|
|
raise ValueError("agent_key and title are required")
|
|
|
|
row = fetch_one(
|
|
"""
|
|
INSERT INTO agent_action_requests (agent_key, action_type, title, query_payload, status)
|
|
VALUES (%s, %s, %s, %s::jsonb, 'pending')
|
|
RETURNING *
|
|
""",
|
|
(key, action_type, title.strip(), json.dumps(query_payload or {})),
|
|
)
|
|
req = _serialize(row) or {}
|
|
|
|
try:
|
|
execute(
|
|
"""
|
|
INSERT INTO agent_events (agent_name, agent_type, event_type, title, body, status, channel, metadata)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
""",
|
|
(
|
|
key,
|
|
"agent_request",
|
|
"approval_request",
|
|
title.strip(),
|
|
f"Wacht op goedkeuring — {action_type}",
|
|
"needs_approval",
|
|
"agents",
|
|
json.dumps({"request_id": req.get("id"), "action_type": action_type}),
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return req
|
|
|
|
|
|
def list_requests(status: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
clauses, params = [], []
|
|
if status:
|
|
clauses.append("status = %s")
|
|
params.append(status)
|
|
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
|
|
safe_limit = max(1, min(limit, 200))
|
|
rows = fetch_all(
|
|
f"SELECT * FROM agent_action_requests{where} ORDER BY created_at DESC LIMIT %s",
|
|
tuple(params + [safe_limit]),
|
|
)
|
|
return [_serialize(r) for r in rows]
|
|
|
|
|
|
def get_request(request_id: int) -> dict[str, Any] | None:
|
|
return _serialize(fetch_one("SELECT * FROM agent_action_requests WHERE id = %s", (request_id,)))
|
|
|
|
|
|
def approve_request(request_id: int, approved_by: str = "ceo") -> dict[str, Any]:
|
|
row = fetch_one(
|
|
"""
|
|
UPDATE agent_action_requests
|
|
SET status = 'approved', approved_by = %s, reviewed_at = NOW()
|
|
WHERE id = %s AND status = 'pending'
|
|
RETURNING *
|
|
""",
|
|
(approved_by, request_id),
|
|
)
|
|
if not row:
|
|
raise ValueError("Request not found or not pending")
|
|
req = _serialize(row) or {}
|
|
try:
|
|
execute(
|
|
"""
|
|
UPDATE agent_events SET status = 'approved'
|
|
WHERE status = 'needs_approval'
|
|
AND metadata->>'request_id' = %s
|
|
""",
|
|
(str(request_id),),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return req
|
|
|
|
|
|
def reject_request(request_id: int, reason: str = "", rejected_by: str = "ceo") -> dict[str, Any]:
|
|
row = fetch_one(
|
|
"""
|
|
UPDATE agent_action_requests
|
|
SET status = 'rejected', approved_by = %s, rejection_reason = %s, reviewed_at = NOW()
|
|
WHERE id = %s AND status = 'pending'
|
|
RETURNING *
|
|
""",
|
|
(rejected_by, (reason or "")[:500], request_id),
|
|
)
|
|
if not row:
|
|
raise ValueError("Request not found or not pending")
|
|
req = _serialize(row) or {}
|
|
try:
|
|
execute(
|
|
"""
|
|
UPDATE agent_events SET status = 'rejected'
|
|
WHERE status = 'needs_approval'
|
|
AND metadata->>'request_id' = %s
|
|
""",
|
|
(str(request_id),),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return req
|
|
|
|
|
|
def mark_executed(request_id: int, result: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
row = fetch_one(
|
|
"""
|
|
UPDATE agent_action_requests
|
|
SET status = 'executed', executed_at = NOW(), result = %s::jsonb
|
|
WHERE id = %s AND status = 'approved'
|
|
RETURNING *
|
|
""",
|
|
(json.dumps(result or {}), request_id),
|
|
)
|
|
if not row:
|
|
raise ValueError("Request not approved or not found")
|
|
req = _serialize(row) or {}
|
|
|
|
try:
|
|
from app.services import projects as project_svc
|
|
|
|
payload = req.get("query_payload") or {}
|
|
if isinstance(payload, str):
|
|
import json as _json
|
|
try:
|
|
payload = _json.loads(payload)
|
|
except Exception:
|
|
payload = {}
|
|
pid = payload.get("project_id")
|
|
project_svc.register_agent_output(
|
|
asset_type=str(req.get("action_type") or "agent_action"),
|
|
title=req.get("title") or f"Agent actie #{request_id}",
|
|
ref_id=str(request_id),
|
|
payload={"result": result or {}, "action_type": req.get("action_type")},
|
|
project_id=int(pid) if pid else None,
|
|
source_agent=str(req.get("agent_key") or "agent"),
|
|
created_by=str(req.get("approved_by") or "ceo"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return req
|
|
|
|
|
|
def require_approved(request_id: int) -> dict[str, Any]:
|
|
req = get_request(request_id)
|
|
if not req:
|
|
raise PermissionError("Approval request not found")
|
|
if req.get("status") != "approved":
|
|
raise PermissionError(f"Request status is {req.get('status')}, approval required")
|
|
return req
|