22a17ed048
Souls now aggregate aliased events (e.g. halal_engine), KPIs reflect 6h activity, active agents animate, and morning scheduler/scripts target cockpit :8610.
209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
"""Agent soul profiles and Herman permissions."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Optional
|
|
|
|
from app.db import execute, fetch_all, fetch_one
|
|
from app.services.agent_names import aliases_for_key, normalize_agent_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"
|
|
|
|
|
|
def _aggregate_event_stats() -> dict[str, dict[str, Any]]:
|
|
rows = fetch_all(
|
|
"""
|
|
SELECT agent_name, title, status, created_at
|
|
FROM agent_events
|
|
WHERE created_at >= NOW() - INTERVAL '30 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,
|
|
{
|
|
"event_count": 0,
|
|
"events_6h": 0,
|
|
"events_24h": 0,
|
|
"errors_24h": 0,
|
|
"last_event_at": None,
|
|
"last_event_title": None,
|
|
"current_status": None,
|
|
"current_event_type": None,
|
|
},
|
|
)
|
|
bucket["event_count"] += 1
|
|
created = r.get("created_at")
|
|
if bucket["last_event_at"] is None:
|
|
bucket["last_event_at"] = created
|
|
bucket["last_event_title"] = r.get("title")
|
|
bucket["current_status"] = r.get("status")
|
|
if created is not None:
|
|
if hasattr(created, "tzinfo") and created.tzinfo is None:
|
|
created = created.replace(tzinfo=timezone.utc)
|
|
age_ok = True
|
|
try:
|
|
if created >= now - timedelta(hours=6):
|
|
bucket["events_6h"] += 1
|
|
if created >= now - timedelta(hours=24):
|
|
bucket["events_24h"] += 1
|
|
if str(r.get("status") or "") in ("error", "rejected"):
|
|
bucket["errors_24h"] += 1
|
|
except Exception:
|
|
age_ok = False
|
|
_ = age_ok
|
|
# Also count older totals beyond 30d window lightly
|
|
try:
|
|
totals = fetch_all(
|
|
"""
|
|
SELECT agent_name, COUNT(*)::int AS c
|
|
FROM agent_events
|
|
GROUP BY agent_name
|
|
"""
|
|
)
|
|
total_by: dict[str, int] = {}
|
|
for r in totals:
|
|
k = normalize_agent_key(str(r.get("agent_name") or ""))
|
|
if not k:
|
|
continue
|
|
total_by[k] = total_by.get(k, 0) + int(r.get("c") or 0)
|
|
for k, c in total_by.items():
|
|
bucket = by_key.setdefault(
|
|
k,
|
|
{
|
|
"event_count": 0,
|
|
"events_6h": 0,
|
|
"events_24h": 0,
|
|
"errors_24h": 0,
|
|
"last_event_at": None,
|
|
"last_event_title": None,
|
|
"current_status": None,
|
|
"current_event_type": None,
|
|
},
|
|
)
|
|
bucket["event_count"] = c
|
|
except Exception:
|
|
pass
|
|
return by_key
|
|
|
|
|
|
def list_souls() -> list[dict[str, Any]]:
|
|
rows = fetch_all("SELECT * FROM agent_souls ORDER BY display_name")
|
|
stats = _aggregate_event_stats()
|
|
out: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
soul = dict(r)
|
|
key = str(soul.get("agent_key") or "").lower()
|
|
st = stats.get(key, {})
|
|
events_6h = int(st.get("events_6h") or 0)
|
|
errors_24h = int(st.get("errors_24h") or 0)
|
|
event_count = int(st.get("event_count") or 0)
|
|
health = _node_health(events_6h, errors_24h, event_count)
|
|
lat = st.get("last_event_at")
|
|
if lat is not None and hasattr(lat, "isoformat"):
|
|
lat = lat.isoformat()
|
|
soul["event_count"] = event_count
|
|
soul["events_6h"] = events_6h
|
|
soul["events_24h"] = int(st.get("events_24h") or 0)
|
|
soul["errors_24h"] = errors_24h
|
|
soul["health"] = health
|
|
soul["last_event_at"] = lat
|
|
soul["current_task"] = st.get("last_event_title") or soul.get("current_task")
|
|
soul["current_status"] = st.get("current_status")
|
|
# Working = recent activity (not just DB is_active flag)
|
|
soul["working"] = events_6h > 0
|
|
soul["activity"] = (
|
|
"busy" if (str(soul.get("current_status") or "").lower() in ("needs_approval", "pending") or events_6h >= 5)
|
|
else ("active" if events_6h > 0 else ("idle" if event_count > 0 else "offline"))
|
|
)
|
|
out.append(soul)
|
|
return out
|
|
|
|
|
|
def get_soul(agent_key: str) -> Optional[dict[str, Any]]:
|
|
row = fetch_one("SELECT * FROM agent_souls WHERE agent_key = %s", (agent_key.lower(),))
|
|
if not row:
|
|
return None
|
|
out = dict(row)
|
|
key = agent_key.lower()
|
|
stats = _aggregate_event_stats().get(key, {})
|
|
out["event_count"] = int(stats.get("event_count") or 0)
|
|
out["events_6h"] = int(stats.get("events_6h") or 0)
|
|
out["health"] = _node_health(out["events_6h"], int(stats.get("errors_24h") or 0), out["event_count"])
|
|
out["recent_events"] = list_agent_events(agent_key, limit=15)
|
|
return out
|
|
|
|
|
|
def list_agent_events(agent_key: str, limit: int = 50) -> list[dict[str, Any]]:
|
|
names = aliases_for_key(agent_key)
|
|
if not names:
|
|
return []
|
|
placeholders = ",".join(["%s"] * len(names))
|
|
rows = fetch_all(
|
|
f"""SELECT id, agent_name, event_type, title, body, status, channel, metadata, created_at, completed_at
|
|
FROM agent_events
|
|
WHERE LOWER(agent_name) IN ({placeholders})
|
|
ORDER BY created_at DESC
|
|
LIMIT %s""",
|
|
(*[n.lower() for n in names], limit),
|
|
)
|
|
out: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
ev = dict(row)
|
|
for key in ("created_at", "completed_at"):
|
|
if ev.get(key) is not None and hasattr(ev[key], "isoformat"):
|
|
ev[key] = ev[key].isoformat()
|
|
out.append(ev)
|
|
return out
|
|
|
|
|
|
def update_soul(agent_key: str, **fields: Any) -> dict[str, Any]:
|
|
allowed = ("display_name", "role_title", "soul_md", "responsibilities", "permissions", "is_active")
|
|
sets, params = [], []
|
|
for k, v in fields.items():
|
|
if k in allowed and v is not None:
|
|
sets.append(f"{k} = %s")
|
|
params.append(v)
|
|
if not sets:
|
|
soul = get_soul(agent_key)
|
|
if not soul:
|
|
raise ValueError("Agent not found")
|
|
return soul
|
|
params.append(agent_key.lower())
|
|
execute(f"UPDATE agent_souls SET {', '.join(sets)}, updated_at = NOW() WHERE agent_key = %s", tuple(params))
|
|
return get_soul(agent_key) or {}
|
|
|
|
|
|
def list_permissions() -> list[dict[str, Any]]:
|
|
return [dict(r) for r in fetch_all("SELECT * FROM herman_permissions ORDER BY category, module_label")]
|
|
|
|
|
|
def update_permission(module_key: str, granted: bool) -> dict[str, Any]:
|
|
execute(
|
|
"""UPDATE herman_permissions SET granted = %s, granted_at = CASE WHEN %s THEN NOW() ELSE NULL END, updated_at = NOW()
|
|
WHERE module_key = %s""",
|
|
(granted, granted, module_key),
|
|
)
|
|
row = fetch_one("SELECT * FROM herman_permissions WHERE module_key = %s", (module_key,))
|
|
return dict(row or {})
|
|
|
|
|
|
def grant_all_permissions() -> int:
|
|
execute("UPDATE herman_permissions SET granted = TRUE, granted_at = NOW(), updated_at = NOW()")
|
|
row = fetch_one("SELECT COUNT(*) AS n FROM herman_permissions WHERE granted = TRUE")
|
|
return int((row or {}).get("n") or 0)
|