diff --git a/projects/ops-desk/api/app/agents/catalog.py b/projects/ops-desk/api/app/agents/catalog.py new file mode 100644 index 0000000..2f3508d --- /dev/null +++ b/projects/ops-desk/api/app/agents/catalog.py @@ -0,0 +1,208 @@ +"""Catálogo nomeado dos Agentics A0–A7 — Spec 027 + 029.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentProfile: + id: str + codename: str + name: str + role: str + reads: tuple[str, ...] + actions: tuple[str, ...] + approval: str + scenarios: tuple[str, ...] = () + + +AGENT_CATALOG: dict[str, AgentProfile] = { + "A0": AgentProfile( + id="A0", + codename="orchestrator", + name="Maestro", + role="Orquestrador multi-agente", + reads=("todos os feeds", "action_log", "findings abertos", "threads activas"), + actions=( + "Delegar cenários aos agentes especializados", + "Sintetizar estado global do ambiente Ligbox", + "Abrir thread de coordenação entre agentes", + "Escalar para humano quando confiança < limiar", + ), + approval="agentic_operator / ops_lead", + scenarios=(), + ), + "A1": AgentProfile( + id="A1", + codename="node_health", + name="Pulso", + role="Saúde de nós e serviços Carbonio", + reads=("métricas VM112", "CPU/RAM Proxmox", "status containers"), + actions=( + "Detectar serviço Carbonio/wizard down", + "Criar finding + alerta ticket", + "Sugerir restart (info auto; restart com ops_lead)", + ), + approval="auto (info) · ops_lead (restart)", + scenarios=("wizard.vm112.bundle", "proxmox.cluster"), + ), + "A2": AgentProfile( + id="A2", + codename="infra_mail", + name="Trilho", + role="DNS, certificados, Traefik, nginx", + reads=("DNS Cloudflare", "certs LE", "Traefik CT114", "SNI"), + actions=( + "Validar propagação DNS pós-onboard", + "Detectar cert expirado ou mismatch SNI", + "Propor fix DNS/Traefik (nunca aplicar sem devops/ops_lead)", + ), + approval="devops ou ops_lead antes de aplicar", + scenarios=("pfsense.api.system",), + ), + "A3": AgentProfile( + id="A3", + codename="deliverability", + name="Carta", + role="SPF, DKIM, DMARC, reputação mail", + reads=("registos DNS mail", "relatórios entregabilidade"), + actions=( + "Auditar SPF/DKIM/DMARC por domínio tenant", + "Gerar relatório de entregabilidade", + "Abrir finding para seo/technician revisão", + ), + approval="seo / technician revisão", + scenarios=(), + ), + "A4": AgentProfile( + id="A4", + codename="security_mail", + name="Escudo Mail", + role="amavis, spam, clamav, filas mail", + reads=("filas mail", "logs amavis/clamav", "quarentena"), + actions=( + "Detectar pico spam ou fila bloqueada", + "Sugerir quarentena / release", + "Correlacionar com alertas segurança VM112", + ), + approval="security_analyst", + scenarios=(), + ), + "A5": AgentProfile( + id="A5", + codename="wazuh_soc", + name="Sentinela SOC", + role="Correlação SIEM Wazuh VM104", + reads=("alertas Wazuh", "webhooks vm104", "tickets correlacionados"), + actions=( + "Correlacionar alerta L≥10 com domínio/sessão", + "Enriquecer timeline do chamado", + "Propor runbook R0/R1 segurança", + ), + approval="security_analyst / noc", + scenarios=(), + ), + "A6": AgentProfile( + id="A6", + codename="support_copilot", + name="Copiloto", + role="Assistência tickets e janela humana", + reads=("tickets Desk", "timeline onboarding", "findings", "KB specs"), + actions=( + "Rascunhar resposta ao cliente/ticket", + "Responder janela /chat do operador humano", + "Resumir contexto para technician enviar", + ), + approval="technician envia · agentic_operator vê tudo", + scenarios=("funnel.stuck.onboarding",), + ), + "A7": AgentProfile( + id="A7", + codename="remediation", + name="Remediador", + role="Runbooks aprovados pós-incidente", + reads=("playbooks aprovados", "findings critical/high", "aprovações pendentes"), + actions=( + "Propor runbook com confiança %", + "Executar R0 auto (poll/refresh)", + "Executar R1+ apenas após OK humano", + "Registar action_executed na timeline", + ), + approval="agentic_operator obrigatório (R2/R3 dupla)", + scenarios=(), + ), + "sentinel": AgentProfile( + id="sentinel", + codename="sentinel", + name="Vigia", + role="Health checks T0 — APIs e infra", + reads=("endpoints HTTP", "integrações health", "Ollama", "VM123 stack"), + actions=( + "Executar cenários desk/wizard/pfsense/proxmox/ollama/VM123", + "Criar findings com severidade", + "Disparar e-mail em high/critical", + ), + approval="automático (detecção) · humano trata finding", + scenarios=( + "desk.api.health", + "wizard.vm112.bundle", + "pfsense.api.system", + "integration.webhook.gap", + "proxmox.cluster", + "ollama.vm123.health", + "vm123.finance.stack", + "vm123.openpanel.bridge", + ), + ), + "curator": AgentProfile( + id="curator", + codename="curator", + name="Curador", + role="Base de conhecimento (RAG)", + reads=("specs/**/*.md", "agent_kb_chunks"), + actions=("Indexar specs no SQLite", "Fornecer snippets ao Copiloto/Advisor"), + approval="automático", + scenarios=(), + ), +} + +# Map legacy agent_id in scenarios → A* principal +SCENARIO_AGENT_MAP = { + "desk.api.health": "sentinel", + "wizard.vm112.bundle": "A1", + "pfsense.api.system": "A2", + "funnel.stuck.onboarding": "A6", + "integration.webhook.gap": "sentinel", + "proxmox.cluster": "A1", + "ollama.vm123.health": "sentinel", + "vm123.finance.stack": "sentinel", + "vm123.openpanel.bridge": "sentinel", +} + + +def resolve_agent(scenario_id: str, agent_id: str | None = None) -> AgentProfile: + key = SCENARIO_AGENT_MAP.get(scenario_id) or agent_id or "sentinel" + return AGENT_CATALOG.get(key, AGENT_CATALOG["sentinel"]) + + +def roster_public() -> list[dict]: + order = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "sentinel", "curator"] + out = [] + for k in order: + if k not in AGENT_CATALOG: + continue + p = AGENT_CATALOG[k] + out.append( + { + "id": p.id, + "codename": p.codename, + "name": p.name, + "role": p.role, + "reads": list(p.reads), + "actions": list(p.actions), + "approval": p.approval, + "scenarios": list(p.scenarios), + } + ) + return out diff --git a/projects/ops-desk/api/app/agents/messages.py b/projects/ops-desk/api/app/agents/messages.py new file mode 100644 index 0000000..cc01859 --- /dev/null +++ b/projects/ops-desk/api/app/agents/messages.py @@ -0,0 +1,289 @@ +"""Bus de mensagens agente↔agente↔humano — Spec 029.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from typing import Any + +from app.agents.catalog import AGENT_CATALOG, resolve_agent + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_messages_schema(conn: sqlite3.Connection) -> None: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS agent_threads ( + id INTEGER PRIMARY KEY, + subject TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'info', + status TEXT NOT NULL DEFAULT 'open', + primary_agent TEXT NOT NULL, + related_finding_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS agent_messages ( + id INTEGER PRIMARY KEY, + thread_id INTEGER NOT NULL, + from_type TEXT NOT NULL, + from_id TEXT NOT NULL, + to_type TEXT NOT NULL, + to_id TEXT NOT NULL, + body TEXT NOT NULL, + context_json TEXT, + requires_human INTEGER NOT NULL DEFAULT 0, + human_role_hint TEXT, + acknowledged_at TEXT, + acknowledged_by TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (thread_id) REFERENCES agent_threads(id) + ); + CREATE INDEX IF NOT EXISTS idx_agent_messages_thread ON agent_messages(thread_id); + CREATE INDEX IF NOT EXISTS idx_agent_messages_inbox ON agent_messages(requires_human, acknowledged_at); + """) + + +def create_thread( + conn: sqlite3.Connection, + *, + subject: str, + primary_agent: str, + severity: str = "info", + related_finding_id: int | None = None, +) -> int: + now = _now() + return int( + conn.execute( + """INSERT INTO agent_threads (subject, severity, status, primary_agent, related_finding_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?)""", + (subject, severity, "open", primary_agent, related_finding_id, now, now), + ).lastrowid + ) + + +def post_message( + conn: sqlite3.Connection, + *, + thread_id: int, + from_type: str, + from_id: str, + to_type: str, + to_id: str, + body: str, + context: dict | None = None, + requires_human: bool = False, + human_role_hint: str | None = None, +) -> int: + now = _now() + mid = int( + conn.execute( + """INSERT INTO agent_messages + (thread_id, from_type, from_id, to_type, to_id, body, context_json, requires_human, human_role_hint, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + thread_id, + from_type, + from_id, + to_type, + to_id, + body, + json.dumps(context or {}), + 1 if requires_human else 0, + human_role_hint, + now, + ), + ).lastrowid + ) + conn.execute("UPDATE agent_threads SET updated_at=? WHERE id=?", (now, thread_id)) + return mid + + +def notify_finding_to_operators( + conn: sqlite3.Connection, + *, + finding_id: int, + scenario_id: str, + title: str, + severity: str, + human_action: str, + agent_id: str, +) -> int: + """Abre thread + mensagem para operadores humanos.""" + profile = resolve_agent(scenario_id, agent_id) + role_hint = { + "critical": "agentic_operator", + "high": "ops_lead", + "warn": "technician", + }.get(severity, "technician") + + existing = conn.execute( + "SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'", + (finding_id,), + ).fetchone() + if existing: + thread_id = existing["id"] + else: + thread_id = create_thread( + conn, + subject=title, + primary_agent=profile.id, + severity=severity, + related_finding_id=finding_id, + ) + + agent_name = profile.name + body = ( + f"**{agent_name}** ({profile.id}) detectou: {title}\n\n" + f"Acção sugerida: {human_action or 'Investigar manualmente.'}\n\n" + f"Cenário: `{scenario_id}` · Severidade: **{severity}**" + ) + + # Mensagem agente → humanos (inbox operadores) + post_message( + conn, + thread_id=thread_id, + from_type="agent", + from_id=profile.id, + to_type="human", + to_id=role_hint, + body=body, + context={"finding_id": finding_id, "scenario_id": scenario_id, "severity": severity}, + requires_human=severity in ("high", "critical", "warn"), + human_role_hint=role_hint, + ) + + # Maestro (A0) regista coordenação inter-agente + if profile.id not in ("A0", "orchestrator"): + post_message( + conn, + thread_id=thread_id, + from_type="agent", + from_id="A0", + to_type="agent", + to_id=profile.id, + body=f"Registado finding #{finding_id}. Aguardando acção humana ({role_hint}).", + context={"coordination": True}, + requires_human=False, + ) + + return thread_id + + +def list_inbox(conn: sqlite3.Connection, *, role: str, limit: int = 50) -> list[dict]: + """Mensagens pendentes para operadores humanos.""" + role_priority = { + "super_admin": ("agentic_operator", "ops_lead", "technician", "noc", "devops", "security_analyst"), + "agentic_operator": ("agentic_operator", "ops_lead"), + "ops_lead": ("ops_lead", "agentic_operator", "technician"), + "technician": ("technician",), + "security_analyst": ("security_analyst", "agentic_operator"), + "devops": ("devops", "ops_lead"), + "noc": ("noc",), + "developer": ("developer", "ops_lead"), + } + allowed = role_priority.get(role, (role,)) + + q = """ + SELECT m.*, t.subject AS thread_subject, t.severity AS thread_severity, t.primary_agent + FROM agent_messages m + JOIN agent_threads t ON t.id = m.thread_id + WHERE m.requires_human = 1 AND m.acknowledged_at IS NULL + AND m.to_type = 'human' + ORDER BY m.id DESC LIMIT ? + """ + rows = [dict(r) for r in conn.execute(q, (limit * 3,))] + out = [] + for r in rows: + hint = r.get("human_role_hint") or r.get("to_id") or "" + if role == "super_admin" or hint in allowed or role in allowed: + r["agent_name"] = AGENT_CATALOG.get(r["from_id"], AGENT_CATALOG.get("sentinel")).name + out.append(r) + if len(out) >= limit: + break + return out + + +def list_threads(conn: sqlite3.Connection, *, limit: int = 40) -> list[dict]: + rows = conn.execute( + "SELECT * FROM agent_threads ORDER BY updated_at DESC LIMIT ?", (limit,) + ).fetchall() + out = [] + for r in rows: + item = dict(r) + p = AGENT_CATALOG.get(item["primary_agent"]) + item["agent_name"] = p.name if p else item["primary_agent"] + pending = conn.execute( + "SELECT COUNT(*) c FROM agent_messages WHERE thread_id=? AND requires_human=1 AND acknowledged_at IS NULL", + (item["id"],), + ).fetchone()["c"] + item["pending_human"] = pending + out.append(item) + return out + + +def thread_messages(conn: sqlite3.Connection, thread_id: int) -> list[dict]: + rows = conn.execute( + "SELECT * FROM agent_messages WHERE thread_id=? ORDER BY id ASC", (thread_id,) + ).fetchall() + out = [] + for r in rows: + item = dict(r) + if item["from_type"] == "agent": + p = AGENT_CATALOG.get(item["from_id"]) + item["from_label"] = f"{p.name} ({item['from_id']})" if p else item["from_id"] + else: + item["from_label"] = item["from_id"] + out.append(item) + return out + + +def human_reply( + conn: sqlite3.Connection, + *, + thread_id: int, + username: str, + body: str, + target_agent: str | None = None, +) -> int: + agent_to = target_agent or conn.execute( + "SELECT primary_agent FROM agent_threads WHERE id=?", (thread_id,) + ).fetchone()["primary_agent"] + + mid = post_message( + conn, + thread_id=thread_id, + from_type="human", + from_id=username, + to_type="agent", + to_id=agent_to, + body=body, + requires_human=False, + ) + + # Copiloto (A6) ecoa confirmação para o thread + post_message( + conn, + thread_id=thread_id, + from_type="agent", + from_id="A6", + to_type="human", + to_id=username, + body=f"Recebi a sua instrução. Vou coordenar com **{AGENT_CATALOG.get(agent_to, AGENT_CATALOG['A6']).name}** e actualizar o finding se aplicável.", + requires_human=False, + ) + return mid + + +def ack_message(conn: sqlite3.Connection, message_id: int, username: str) -> bool: + row = conn.execute("SELECT id FROM agent_messages WHERE id=?", (message_id,)).fetchone() + if not row: + return False + conn.execute( + "UPDATE agent_messages SET acknowledged_at=?, acknowledged_by=? WHERE id=?", + (_now(), username, message_id), + ) + return True diff --git a/projects/ops-desk/api/app/agents/routes.py b/projects/ops-desk/api/app/agents/routes.py index af71b55..8d8d790 100644 --- a/projects/ops-desk/api/app/agents/routes.py +++ b/projects/ops-desk/api/app/agents/routes.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, Field from app import auth from app.agents import llm_client, runner, store +from app.agents import messages as agent_messages +from app.agents.catalog import roster_public router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) @@ -37,6 +39,12 @@ def _ops_view(user): class ChatRequest(BaseModel): question: str = Field(..., min_length=2, max_length=4000) include_findings: bool = True + target_agent: str = Field(default="A6", description="Agente destino — default Copiloto") + + +class ReplyRequest(BaseModel): + body: str = Field(..., min_length=1, max_length=8000) + target_agent: str | None = None @router.get("/health") @@ -51,6 +59,65 @@ def agents_health(): } +@router.get("/roster") +def agents_roster(user=Depends(auth.get_current_user)): + _ops_view(user) + return {"agents": roster_public()} + + +@router.get("/inbox") +def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)): + _ops_view(user) + return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)} + + +@router.get("/threads") +def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)): + _ops_view(user) + return {"threads": agent_messages.list_threads(conn, limit=limit)} + + +@router.get("/threads/{thread_id}/messages") +def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user) + if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone(): + raise HTTPException(404, "thread not found") + return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)} + + +@router.post("/threads/{thread_id}/reply") +def thread_reply( + thread_id: int, + body: ReplyRequest, + user=Depends(auth.get_current_user), + conn=Depends(_db), +): + _ops_view(user) + if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone(): + raise HTTPException(404, "thread not found") + mid = agent_messages.human_reply( + conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=body.target_agent + ) + store.log_event( + conn, + event_type="human.reply", + message=body.body[:120], + agent_id=body.target_agent or "A6", + payload={"thread_id": thread_id, "user": user.username}, + ) + conn.commit() + return {"ok": True, "message_id": mid} + + +@router.post("/messages/{message_id}/ack") +def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user) + if not agent_messages.ack_message(conn, message_id, user.username): + raise HTTPException(404, "not found") + conn.commit() + return {"ok": True, "id": message_id} + + @router.get("/scenarios") def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user) @@ -103,7 +170,7 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe @router.post("/chat") def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): - """T1 context window — copiloto ops para utilizadores Desk.""" + """Janela de contexto T1 — humano ↔ agente (default Copiloto A6).""" _ops_view(user) kb = store.search_kb(conn, body.question) findings_summary = "" @@ -119,15 +186,40 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe findings_summary=findings_summary, user_role=user.role, ) + thread_id = agent_messages.create_thread( + conn, + subject=f"Chat: {body.question[:60]}", + primary_agent=body.target_agent, + severity="info", + ) + agent_messages.post_message( + conn, + thread_id=thread_id, + from_type="human", + from_id=user.username, + to_type="agent", + to_id=body.target_agent, + body=body.question, + ) + agent_messages.post_message( + conn, + thread_id=thread_id, + from_type="agent", + from_id=body.target_agent, + to_type="human", + to_id=user.username, + body=answer, + context={"model": model, "kb_hits": len(kb)}, + ) store.log_event( conn, event_type="chat.query", message=body.question[:120], - agent_id="advisor", - payload={"user": user.username, "model": model}, + agent_id=body.target_agent, + payload={"user": user.username, "model": model, "thread_id": thread_id}, ) conn.commit() - return {"answer": answer, "model": model, "kb_hits": len(kb)} + return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id} @router.post("/internal/tick") @@ -138,7 +230,7 @@ def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db) conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", - agent_id="orchestrator", + agent_id="A0", payload={"kb": kb, **result}, ) conn.commit() diff --git a/projects/ops-desk/api/app/agents/runner.py b/projects/ops-desk/api/app/agents/runner.py index ac24179..5b63b95 100644 --- a/projects/ops-desk/api/app/agents/runner.py +++ b/projects/ops-desk/api/app/agents/runner.py @@ -3,6 +3,8 @@ from __future__ import annotations import os from pathlib import Path from app.agents import checks, llm_client, notify, registry, store +from app.agents import messages as agent_messages +from app.agents.catalog import SCENARIO_AGENT_MAP SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs")) OPS_API = os.getenv("OPS_API_URL", "http://api:8080") @@ -30,7 +32,7 @@ def run_scenario(conn, scenario_id, *, trigger="cron"): sc = store.get_scenario(conn, scenario_id) if not sc: return {"ok": False, "error": "not found"} - agent_id = (sc.get("config") or {}).get("agent_id") or sc.get("agent_id") or "sentinel" + agent_id = SCENARIO_AGENT_MAP.get(scenario_id) or (sc.get("config") or {}).get("agent_id") or "sentinel" fn = checks.SCENARIO_RUNNERS.get(scenario_id) if not fn: return {"ok": False, "error": "no runner"} @@ -51,6 +53,16 @@ def run_scenario(conn, scenario_id, *, trigger="cron"): category=f.get("category","api"), title=f.get("title","Finding"), detail_md=f.get("detail_md",""), evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb]) fids.append(fid) + if f.get("severity") in ("warn", "high", "critical"): + agent_messages.notify_finding_to_operators( + conn, + finding_id=fid, + scenario_id=scenario_id, + title=f.get("title", "Finding"), + severity=f.get("severity", "warn"), + human_action=human, + agent_id=agent_id, + ) if f.get("severity") in ("high", "critical"): notify.notify_finding({**f, "suggested_human_action": human}) store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id) diff --git a/projects/ops-desk/api/app/agents/scenarios/registry.yaml b/projects/ops-desk/api/app/agents/scenarios/registry.yaml index 97629a3..5a9eafb 100644 --- a/projects/ops-desk/api/app/agents/scenarios/registry.yaml +++ b/projects/ops-desk/api/app/agents/scenarios/registry.yaml @@ -7,15 +7,15 @@ scenarios: - id: wizard.vm112.bundle title: VM112 Wizard severity_default: high - agent_id: sentinel + agent_id: A1 - id: pfsense.api.system title: pfSense API severity_default: warn - agent_id: sentinel + agent_id: A2 - id: funnel.stuck.onboarding title: Funil travado severity_default: warn - agent_id: dispatcher + agent_id: A6 - id: integration.webhook.gap title: Gap webhook VM112 severity_default: high @@ -23,7 +23,7 @@ scenarios: - id: proxmox.cluster title: Proxmox VMs críticas severity_default: critical - agent_id: sentinel + agent_id: A1 - id: ollama.vm123.health title: Ollama VM123 severity_default: high diff --git a/projects/ops-desk/api/app/agents/store.py b/projects/ops-desk/api/app/agents/store.py index 4afba6a..ec5e9d2 100644 --- a/projects/ops-desk/api/app/agents/store.py +++ b/projects/ops-desk/api/app/agents/store.py @@ -4,10 +4,13 @@ import json, sqlite3 from datetime import datetime, timezone from typing import Any +from app.agents.messages import init_messages_schema + def _now(): return datetime.now(timezone.utc).isoformat() def init_agent_schema(conn): + init_messages_schema(conn) conn.executescript(""" CREATE TABLE IF NOT EXISTS agent_scenarios ( id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT, diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js index 321c458..2242db1 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.js +++ b/projects/ops-desk/frontend/assets/agentic-ops.js @@ -1,20 +1,59 @@ (function () { const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>'); + let state = { threadId: null, selectedAgent: 'A6' }; async function api(path, opts = {}) { - const h = { ...(opts.headers || {}), 'Content-Type': 'application/json' }; + const h = { ...(opts.headers || {}) }; + if (!(opts.body instanceof FormData)) h['Content-Type'] = 'application/json'; const t = window.DeskAuth?.getToken?.(); if (t) h.Authorization = `Bearer ${t}`; const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h }); - if (!r.ok) { - const err = await r.text(); - throw new Error(`${r.status} ${err.slice(0, 200)}`); - } + if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`); return r.json(); } - async function sendChat(question) { - return api('/chat', { method: 'POST', body: JSON.stringify({ question, include_findings: true }) }); + function agentCard(a) { + const active = state.selectedAgent === a.id ? ' agentic-agent-active' : ''; + return `
+

${esc(a.name)} ${esc(a.id)}

+

${esc(a.role)}

+ +

Aprovação: ${esc(a.approval)}

+
`; + } + + function inboxRow(m) { + return `
+
+ ${esc(m.agent_name || m.from_id)} + ${esc(m.thread_severity || 'info')} +
+

${esc(m.thread_subject || m.message)} · ${esc(m.created_at)}

+

${esc((m.body || '').slice(0, 280))}

+
+ + +
+
`; + } + + function threadBubble(m) { + const isHuman = m.from_type === 'human'; + const cls = isHuman ? 'agentic-bubble-human' : 'agentic-bubble-agent'; + return `
+
${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}
+
${esc(m.body).replace(/\n/g, '
')}
+
`; + } + + async function loadThread(el, threadId) { + state.threadId = threadId; + const box = el.querySelector('#agentic-thread-messages'); + if (!box) return; + box.innerHTML = '

Carregando thread…

'; + const data = await api(`/threads/${threadId}/messages`); + box.innerHTML = data.messages.map(threadBubble).join('') || '

Sem mensagens.

'; + box.scrollTop = box.scrollHeight; } async function renderAgenticOps() { @@ -22,53 +61,112 @@ if (!el) return; el.innerHTML = '

Carregando Agentic Ops…

'; try { - const [health, scenarios, findings, log] = await Promise.all([ - api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'), + const [health, roster, inbox, threads, findings] = await Promise.all([ + api('/health'), + api('/roster'), + api('/inbox?limit=20'), + api('/threads?limit=15'), + api('/findings?limit=15'), ]); const tier = health.tier === 't1' ? 'T1 LLM' : 'T0'; const ollama = health.ollama - ? `Ollama OK · ${esc(health.model || '')}` - : 'Ollama offline — modo T0'; - const sRows = (scenarios.scenarios || []).map(s => - `${esc(s.id)}${esc(s.title)}${esc(s.last_run_status||'—')}${esc(s.last_run_at||'—')}` + ? `Ollama · ${esc(health.model)}` + : 'Ollama offline'; + const agents = roster.agents || []; + const inboxItems = inbox.messages || []; + const threadOpts = (threads.threads || []).map(t => + `` ).join(''); const fRows = (findings.findings || []).map(f => - `

${esc(f.title)} ${esc(f.severity)}

` - + `

${esc(f.created_at)}

` - + (f.suggested_human_action ? `

Acção: ${esc(f.suggested_human_action)}

` : '') - + `
` - ).join('') || '

Sem findings abertos.

'; - const lRows = (log.events || []).map(e => - `${esc(e.ts)}${esc(e.event_type)}${esc(e.message)}` - ).join(''); + `
  • ${esc(f.title)} ${esc(f.severity)} + ${f.suggested_human_action ? `
    ${esc(f.suggested_human_action)}` : ''}
  • ` + ).join('') || '
  • Nenhum finding aberto.
  • '; + el.innerHTML = ` -
    -

    Agentic Ops

    Spec 029 · ${tier} ${ollama}

    - -
    -
    -

    Cenários

    - ${sRows}
    IDTítuloÚltimoQuando
    -
    -

    Findings

    ${fRows}
    -
    -
    -

    Copiloto Ops (T1)

    -

    Pergunte sobre infra, VM123, findings ou procedimentos — resposta contextual pt-BR.

    -
    - - -
    - -
    -

    Audit log

    - ${lRows}
    QuandoEventoMensagem
    -
    `; + +
    +

    Agentic Ops

    Spec 029 · ${tier} ${ollama} · ${inboxItems.length} pendente(s)

    + +
    +
    +
    +

    Agentes (A0–A7)

    +

    Clique para seleccionar destino do chat.

    + ${agents.map(agentCard).join('')} +
    +
    +

    Inbox operadores

    +

    Mensagens dos agentes que exigem acção humana.

    + ${inboxItems.length ? inboxItems.map(inboxRow).join('') : '

    Inbox vazia.

    '} +

    Findings abertos

    + +
    +
    +

    Janela de contexto

    + +

    Seleccione uma thread ou abra da inbox.

    + + +
    +

    Chat Copiloto (${esc(state.selectedAgent)})

    +
    + + +
    + +
    +
    `; + el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps); - el.querySelectorAll('[data-ack]').forEach(btn => btn.addEventListener('click', async () => { - await api(`/findings/${btn.dataset.ack}/ack`, { method: 'POST' }); - await renderAgenticOps(); - })); + el.querySelectorAll('.agentic-agent-card').forEach(card => { + card.addEventListener('click', () => { + state.selectedAgent = card.dataset.agentId; + renderAgenticOps(); + }); + }); + el.querySelectorAll('[data-open-thread]').forEach(btn => { + btn.addEventListener('click', () => loadThread(el, parseInt(btn.dataset.openThread, 10))); + }); + el.querySelectorAll('[data-ack-msg]').forEach(btn => { + btn.addEventListener('click', async () => { + await api(`/messages/${btn.dataset.ackMsg}/ack`, { method: 'POST' }); + await renderAgenticOps(); + }); + }); + el.querySelector('#agentic-thread-select')?.addEventListener('change', (e) => { + const id = parseInt(e.target.value, 10); + if (id) loadThread(el, id); + }); + el.querySelector('#btn-agentic-reply')?.addEventListener('click', async () => { + const input = el.querySelector('#agentic-reply-input'); + const tid = state.threadId || parseInt(el.querySelector('#agentic-thread-select')?.value, 10); + const body = (input?.value || '').trim(); + if (!tid || !body) return; + await api(`/threads/${tid}/reply`, { + method: 'POST', + body: JSON.stringify({ body, target_agent: state.selectedAgent }), + }); + input.value = ''; + await loadThread(el, tid); + }); el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => { const input = el.querySelector('#agentic-chat-input'); const out = el.querySelector('#agentic-chat-answer'); @@ -77,12 +175,20 @@ out.hidden = false; out.innerHTML = '

    A pensar…

    '; try { - const res = await sendChat(q); - out.innerHTML = `

    Resposta (${esc(res.model)})

    ${esc(res.answer)}

    `; + const res = await api('/chat', { + method: 'POST', + body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }), + }); + out.innerHTML = `

    ${esc(state.selectedAgent)} (${esc(res.model)})

    ${esc(res.answer)}

    `; + if (res.thread_id) { + state.threadId = res.thread_id; + await loadThread(el, res.thread_id); + } } catch (err) { out.innerHTML = `

    ${esc(err.message)}

    `; } }); + if (state.threadId) await loadThread(el, state.threadId); } catch (err) { el.innerHTML = `

    Erro: ${esc(err.message)}

    `; } diff --git a/specs/029-agentic-ops-runbooks/agents-roster.md b/specs/029-agentic-ops-runbooks/agents-roster.md new file mode 100644 index 0000000..e5236e6 --- /dev/null +++ b/specs/029-agentic-ops-runbooks/agents-roster.md @@ -0,0 +1,142 @@ +# Roster Agentics — Nomes, acções e janelas de contexto + +**Spec 029 · Secção agentes** + +--- + +## Mapa A0–A7 (nomes operacionais) + +| ID | Nome | Codename | Operador humano principal | +|----|------|----------|---------------------------| +| **A0** | **Maestro** | orchestrator | agentic_operator / ops_lead | +| **A1** | **Pulso** | node_health | ops_lead (restart) | +| **A2** | **Trilho** | infra_mail | devops / ops_lead | +| **A3** | **Carta** | deliverability | seo / technician | +| **A4** | **Escudo Mail** | security_mail | security_analyst | +| **A5** | **Sentinela SOC** | wazuh_soc | security_analyst / noc | +| **A6** | **Copiloto** | support_copilot | technician (envia) | +| **A7** | **Remediador** | remediation | agentic_operator (obrigatório) | + +### Auxiliares (implementação T0) + +| ID | Nome | Função | +|----|------|--------| +| **Vigia** | sentinel | Health checks APIs/VM123 | +| **Curador** | curator | Indexação RAG das specs | + +--- + +## Acções tocadas por cada agente + +### A0 — Maestro +- Dispara tick 24/7 (worker 5 min) +- Indexa KB via Curador +- Delega cenários ao Vigia/Pulso/Copiloto +- Abre threads de coordenação inter-agente +- Regista `tick.complete` no audit log + +### A1 — Pulso +- `wizard.vm112.bundle` — API + portal onboard +- `proxmox.cluster` — VMs 112/122/123/104 running +- Cria finding → inbox ops_lead se VM parada + +### A2 — Trilho +- `pfsense.api.system` — firewall via Traefik +- (futuro) validação DNS/LE pós-onboard +- Propõe fix — **nunca aplica** sem devops + +### A3 — Carta +- (futuro) audit SPF/DKIM/DMARC por tenant +- Relatórios entregabilidade → inbox seo + +### A4 — Escudo Mail +- (futuro) filas amavis/clamav +- Quarentena sugerida → security_analyst + +### A5 — Sentinela SOC +- (futuro) correlacionar Wazuh L≥10 com ticket +- Enriquecer timeline CH-* + +### A6 — Copiloto +- `funnel.stuck.onboarding` — tickets >24h +- **Janela /chat** — responde operadores humanos +- Ecoa confirmação quando humano responde num thread +- Rascunho resposta ticket (futuro) + +### A7 — Remediador +- (futuro) runbooks R0–R3 +- Execução **só após** OK agentic_operator +- Auditoria `action_executed` + +### Vigia (sentinel) +- desk, pfSense, webhook gap, Ollama, FOSS, Odoo, OpenPanel bridge +- E-mail + inbox em high/critical + +### Curador +- Indexa `specs/**/*.md` no tick +- Alimenta RAG do Copiloto/Advisor + +--- + +## Janelas de contexto (como conversam) + +```text + ┌─────────────────────────────────────┐ + │ A0 Maestro (orchestrator) │ + │ tick → delega → sintetiza │ + └──────────┬──────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + A1 Pulso A6 Copiloto Vigia + finding /chat humano cenários T0 + │ │ │ + └──────────┬──────────┴──────────┬──────────┘ + ▼ ▼ + agent_threads agent_messages + │ │ + └──────────┬──────────┘ + ▼ + Inbox operadores humanos + (agentic_operator, ops_lead, …) +``` + +### Regras de conversa + +1. **Agente → Humano:** finding warn+ cria `agent_thread` + mensagem `requires_human=1` na **Inbox**. +2. **Agente → Agente:** Maestro (A0) regista coordenação no mesmo thread («aguardando acção humana»). +3. **Humano → Agente:** operador responde no painel «Janela de contexto» → Copiloto (A6) confirma e encaminha ao agente primary. +4. **Humano ↔ Copiloto:** chat livre via `/chat` com `target_agent` — grava thread persistente. + +### Papéis que veem a Inbox + +| Role Desk | Vê mensagens destinadas a | +|-----------|---------------------------| +| super_admin | todos | +| agentic_operator | agentic_operator, ops_lead | +| ops_lead | ops_lead, agentic_operator, technician | +| technician | technician | +| security_analyst | security_analyst, agentic_operator | +| devops | devops, ops_lead | + +--- + +## API de mensagens + +| Endpoint | Descrição | +|----------|-----------| +| GET `/roster` | Catálogo nomeado | +| GET `/inbox` | Pendências humanas | +| GET `/threads` | Todas as conversas | +| GET `/threads/{id}/messages` | Histórico thread | +| POST `/threads/{id}/reply` | Humano responde | +| POST `/messages/{id}/ack` | Arquivar inbox | +| POST `/chat` | Nova conversa com agente | + +--- + +## UI Desk — painel Agentic Ops + +1. **Coluna esquerda:** cards A0–A7 (clique selecciona agente destino) +2. **Coluna centro:** Inbox operadores + findings abertos +3. **Coluna direita:** Janela de contexto (thread + reply + chat Copiloto) diff --git a/specs/029-agentic-ops-runbooks/spec.md b/specs/029-agentic-ops-runbooks/spec.md index a3fc597..34e38e7 100644 --- a/specs/029-agentic-ops-runbooks/spec.md +++ b/specs/029-agentic-ops-runbooks/spec.md @@ -24,15 +24,22 @@ Camada **Agentic Ops** para vigilância 24/7, checks determinísticos (T0), advi ## Agentes lógicos (implementação 029) -| ID | Papel | Função | -|----|-------|--------| -| `sentinel` | Health/API | Cenários desk, wizard, pfSense, proxmox, ollama, VM123 | -| `dispatcher` | Funil | Tickets onboarding travados | -| `curator` | KB | Indexa `/specs/**/*.md` em SQLite | -| `advisor` | T1 | Sugestões human_action + `/chat` copiloto | -| `orchestrator` | Tick | Worker cron — dispara todos os cenários | +**Documento completo:** [`agents-roster.md`](agents-roster.md) -Mapeamento futuro Spec 027 A0–A7 permanece na governança RBAC; esta spec entrega o **MVP operacional**. +| ID | Nome | Codename | Inbox humano | +|----|------|----------|--------------| +| A0 | Maestro | orchestrator | agentic_operator | +| A1 | Pulso | node_health | ops_lead | +| A2 | Trilho | infra_mail | devops | +| A3 | Carta | deliverability | seo / technician | +| A4 | Escudo Mail | security_mail | security_analyst | +| A5 | Sentinela SOC | wazuh_soc | security_analyst | +| A6 | Copiloto | support_copilot | technician | +| A7 | Remediador | remediation | agentic_operator | +| — | Vigia | sentinel | ops (findings T0) | +| — | Curador | curator | — (RAG interno) | + +Mensagens: tabelas `agent_threads` + `agent_messages`. UI: Inbox + Janela de contexto no Desk. --- @@ -54,6 +61,12 @@ Mapeamento futuro Spec 027 A0–A7 permanece na governança RBAC; esta spec entr | Método | Path | Auth | |--------|------|------| +| GET | `/roster` | ops view — catálogo A0–A7 | +| GET | `/inbox` | ops view — mensagens pendentes | +| GET | `/threads` | ops view | +| GET | `/threads/{id}/messages` | ops view | +| POST | `/threads/{id}/reply` | ops view — humano responde | +| POST | `/messages/{id}/ack` | ops view — arquivar inbox | | GET | `/health` | público | | GET | `/scenarios` | ops view | | GET | `/findings` | ops view |