Compare commits
No commits in common. "92148e5980e1e0159a279645468fde6db42e236d" and "acaacce70522344bbe10e1ae750199dda7668a8e" have entirely different histories.
92148e5980
...
acaacce705
30 changed files with 109 additions and 2108 deletions
|
|
@ -123,7 +123,7 @@
|
||||||
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
|
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
|
||||||
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
|
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
|
||||||
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
|
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
|
||||||
| **AG-1** | P1 | Agentes IA + runbooks (Spec 029) | 🔄 staging |
|
| **AG-1** | P3 | Agentes IA + runbooks | 📋 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# Deploy homologação Agentic Ops — VM122 staging (portas 8180/8192)
|
|
||||||
# NÃO altera produção em :8080/:8091
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
STAGING_DIR="/opt/ligbox-ops-platform-staging"
|
|
||||||
REPO="/opt/ligbox-spec-hub/repos/ligbox-ops-platform"
|
|
||||||
BRANCH="${1:-029-agentic-ops-runbooks}"
|
|
||||||
|
|
||||||
echo "==> Staging Agentic Ops branch=$BRANCH"
|
|
||||||
|
|
||||||
mkdir -p "$STAGING_DIR" /var/lib/ligbox-ops-platform-staging
|
|
||||||
|
|
||||||
# Sync código (symlinks api/frontend/worker do repo)
|
|
||||||
rsync -a --delete \
|
|
||||||
--exclude '.git' --exclude 'chat-bruto' --exclude 'node_modules' \
|
|
||||||
"$REPO/projects/ops-desk/" "$STAGING_DIR/"
|
|
||||||
|
|
||||||
rsync -a "$REPO/specs/" "$STAGING_DIR/specs/"
|
|
||||||
|
|
||||||
cd "$STAGING_DIR"
|
|
||||||
|
|
||||||
if [[ ! -f .env ]]; then
|
|
||||||
if [[ -f /opt/ligbox-ops-platform/.env ]]; then
|
|
||||||
cp /opt/ligbox-ops-platform/.env .env
|
|
||||||
sed -i 's|SQLITE_PATH=.*|SQLITE_PATH=/data/ops-staging.db|' .env
|
|
||||||
echo "AGENTIC_LLM_ENABLED=true" >> .env
|
|
||||||
echo "AGENTIC_SPECS_ROOT=/opt/ligbox-ops-platform/specs" >> .env
|
|
||||||
else
|
|
||||||
echo "ERRO: .env não encontrado — copie manualmente" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
docker compose -f docker-compose.agentic-staging.yml up -d --build
|
|
||||||
|
|
||||||
sleep 8
|
|
||||||
echo "==> Health staging"
|
|
||||||
curl -sf "http://10.10.10.122:8180/api/health" | head -c 200; echo
|
|
||||||
curl -sf "http://10.10.10.122:8180/api/v1/agents/health" | head -c 300; echo
|
|
||||||
|
|
||||||
TOKEN=$(grep OPS_INTERNAL_TOKEN .env | cut -d= -f2)
|
|
||||||
curl -sf -X POST "http://10.10.10.122:8180/api/v1/agents/internal/tick" \
|
|
||||||
-H "X-Ops-Internal-Token: $TOKEN" | head -c 400; echo
|
|
||||||
|
|
||||||
echo "==> Staging UI: http://10.10.10.122:8192"
|
|
||||||
echo "==> Staging API: http://10.10.10.122:8180"
|
|
||||||
|
|
@ -1,208 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
"""T0/T1 checks — Spec 029."""
|
"""T0/T1 checks — Spec 029."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import os, sqlite3, time
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
DESK = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br")
|
DESK = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br")
|
||||||
|
|
@ -15,252 +11,85 @@ PFS_USER = os.getenv("PFSENSE_API_USER", "api_cursor")
|
||||||
PFS_PASS = os.getenv("PFSENSE_API_PASSWORD", "805353")
|
PFS_PASS = os.getenv("PFSENSE_API_PASSWORD", "805353")
|
||||||
PVE = os.getenv("PVE_API_URL", "https://10.10.10.2:8006/api2/json")
|
PVE = os.getenv("PVE_API_URL", "https://10.10.10.2:8006/api2/json")
|
||||||
PVE_USER = os.getenv("PVE_USER", "root@pam")
|
PVE_USER = os.getenv("PVE_USER", "root@pam")
|
||||||
PVE_PASS = os.getenv("PVE_PASSWORD", "")
|
PVE_PASS = os.getenv("PVE_PASSWORD", "@betinplace")
|
||||||
PVE_NODE = os.getenv("PVE_NODE", "big1")
|
PVE_NODE = os.getenv("PVE_NODE", "big1")
|
||||||
VMIDS = [int(x) for x in os.getenv("AGENTIC_CRITICAL_VMIDS", "112,122,123,104").split(",") if x.strip()]
|
VMIDS = [int(x) for x in os.getenv("AGENTIC_CRITICAL_VMIDS", "112,122,123,104").split(",") if x.strip()]
|
||||||
OLLAMA = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
|
OLLAMA = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
|
||||||
VM123_IP = os.getenv("VM123_IP", "10.10.10.123")
|
|
||||||
OPENPANEL_BRIDGE = os.getenv("OPENPANEL_BRIDGE_URL", f"http://{VM123_IP}:18087").rstrip("/")
|
|
||||||
|
|
||||||
|
|
||||||
def _http(url, *, auth=None, max_ms=2500):
|
def _http(url, *, auth=None, max_ms=2500):
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=15, verify=False, follow_redirects=True) as c:
|
with httpx.Client(timeout=15, verify=False, follow_redirects=True) as c:
|
||||||
r = c.get(url, auth=auth)
|
r = c.get(url, auth=auth)
|
||||||
ms = int((time.perf_counter() - t0) * 1000)
|
ms = int((time.perf_counter()-t0)*1000)
|
||||||
return {"ok": r.status_code == 200 and ms <= max_ms, "status_code": r.status_code, "latency_ms": ms, "url": url}
|
return {"ok": r.status_code==200 and ms<=max_ms, "status_code": r.status_code, "latency_ms": ms, "url": url}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "url": url}
|
return {"ok": False, "error": str(e), "url": url}
|
||||||
|
|
||||||
|
|
||||||
def check_desk_api_health():
|
def check_desk_api_health():
|
||||||
r = _http(f"{DESK}/api/health", max_ms=4000)
|
r = _http(f"{DESK}/api/health")
|
||||||
return [] if r["ok"] else [
|
return [] if r["ok"] else [{"severity":"high","category":"api","title":"Desk API health falhou","detail_md":str(r),"evidence":r,"human_action":"docker-compose logs api VM122"}]
|
||||||
{
|
|
||||||
"severity": "high",
|
|
||||||
"category": "api",
|
|
||||||
"title": "Desk API health falhou",
|
|
||||||
"detail_md": str(r),
|
|
||||||
"evidence": r,
|
|
||||||
"human_action": "Verificar docker-compose api VM122",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def check_vm112_health():
|
def check_vm112_health():
|
||||||
out = []
|
out = []
|
||||||
r1 = _http(f"{VM112}/api/onboarding/health")
|
r1 = _http(f"{VM112}/api/onboarding/health")
|
||||||
if not r1["ok"]:
|
if not r1["ok"]: out.append({"severity":"high","category":"api","title":"VM112 API down","detail_md":str(r1),"evidence":r1,"human_action":"systemctl ligbox-wizard VM112"})
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"severity": "high",
|
|
||||||
"category": "api",
|
|
||||||
"title": "VM112 API down",
|
|
||||||
"detail_md": str(r1),
|
|
||||||
"evidence": r1,
|
|
||||||
"human_action": "systemctl ligbox-wizard VM112",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
r2 = _http(WIZARD, max_ms=4000)
|
r2 = _http(WIZARD, max_ms=4000)
|
||||||
if not r2["ok"]:
|
if not r2["ok"]: out.append({"severity":"warn","category":"api","title":"Portal /onboard falhou","detail_md":str(r2),"evidence":r2,"human_action":"Traefik + VM112"})
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "api",
|
|
||||||
"title": "Portal /onboard falhou",
|
|
||||||
"detail_md": str(r2),
|
|
||||||
"evidence": r2,
|
|
||||||
"human_action": "Traefik CT114 + VM112",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def check_pfsense_api():
|
def check_pfsense_api():
|
||||||
r = _http(PFS_URL, auth=(PFS_USER, PFS_PASS), max_ms=4000)
|
r = _http(PFS_URL, auth=(PFS_USER, PFS_PASS), max_ms=4000)
|
||||||
return [] if r["ok"] else [
|
return [] if r["ok"] else [{"severity":"warn","category":"infra","title":"pfSense API falhou","detail_md":str(r),"evidence":r,"human_action":"firewall.itecnologys.com"}]
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "infra",
|
|
||||||
"title": "pfSense API falhou",
|
|
||||||
"detail_md": str(r),
|
|
||||||
"evidence": r,
|
|
||||||
"human_action": "Validar firewall.itecnologys.com via Traefik",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def check_funnel_stuck(conn, max_stuck=5):
|
def check_funnel_stuck(conn, max_stuck=5):
|
||||||
try:
|
try:
|
||||||
c = conn.execute(
|
c = conn.execute("SELECT COUNT(*) n FROM tickets WHERE status IN ('open','assisting','escalated') AND (subject LIKE '%onboarding%' OR payload LIKE '%onboarding%') AND datetime(created_at)<datetime('now','-24 hours')").fetchone()["n"]
|
||||||
"SELECT COUNT(*) n FROM tickets WHERE status IN ('open','assisting','escalated') "
|
if c <= max_stuck: return []
|
||||||
"AND (subject LIKE '%onboarding%' OR payload LIKE '%onboarding%') "
|
return [{"severity":"warn","category":"code","title":f"Funil travado {c} tickets","detail_md":str(c),"evidence":{"count":c},"human_action":"ASM Spec 010"}]
|
||||||
"AND datetime(created_at)<datetime('now','-24 hours')"
|
|
||||||
).fetchone()["n"]
|
|
||||||
if c <= max_stuck:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "code",
|
|
||||||
"title": f"Funil travado {c} tickets",
|
|
||||||
"detail_md": str(c),
|
|
||||||
"evidence": {"count": c},
|
|
||||||
"human_action": "Rever tickets onboarding — Spec 010 Assist",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def check_integration_gap(ops_api_url, token):
|
def check_integration_gap(ops_api_url, token):
|
||||||
if not token:
|
if not token: return []
|
||||||
return []
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=15) as c:
|
with httpx.Client(timeout=15) as c:
|
||||||
r = c.get(f"{ops_api_url}/api/v1/integrations/health", headers={"X-Ops-Internal-Token": token})
|
r = c.get(f"{ops_api_url}/api/v1/integrations/health", headers={"X-Ops-Internal-Token": token})
|
||||||
if r.status_code != 200:
|
if r.status_code != 200: return []
|
||||||
return []
|
|
||||||
gap = (r.json().get("vm112_onboard") or {}).get("gap_minutes")
|
gap = (r.json().get("vm112_onboard") or {}).get("gap_minutes")
|
||||||
if gap is None or int(gap) <= 15:
|
if gap is None or int(gap) <= 15: return []
|
||||||
return []
|
return [{"severity":"high","category":"infra","title":f"Gap webhook {int(gap)}min","detail_md":"VM112 sem eventos","evidence":{"gap":gap},"human_action":"Webhooks VM112→122"}]
|
||||||
return [
|
|
||||||
{
|
|
||||||
"severity": "high",
|
|
||||||
"category": "infra",
|
|
||||||
"title": f"Gap webhook {int(gap)}min",
|
|
||||||
"detail_md": "VM112 sem eventos recentes",
|
|
||||||
"evidence": {"gap": gap},
|
|
||||||
"human_action": "Webhooks VM112→122",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def check_proxmox_cluster():
|
def check_proxmox_cluster():
|
||||||
if not PVE_PASS:
|
|
||||||
return []
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=15, verify=False) as c:
|
with httpx.Client(timeout=15, verify=False) as c:
|
||||||
t = c.post(f"{PVE}/access/ticket", data={"username": PVE_USER, "password": PVE_PASS})
|
t = c.post(f"{PVE}/access/ticket", data={"username": PVE_USER, "password": PVE_PASS})
|
||||||
if t.status_code != 200:
|
if t.status_code != 200:
|
||||||
return [
|
return [{"severity":"warn","category":"infra","title":"Proxmox auth falhou","detail_md":str(t.status_code),"evidence":{},"human_action":"PVE 10.10.10.2:8006"}]
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "infra",
|
|
||||||
"title": "Proxmox auth falhou",
|
|
||||||
"detail_md": str(t.status_code),
|
|
||||||
"evidence": {},
|
|
||||||
"human_action": "PVE 10.10.10.2:8006",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
tok = t.json()["data"]["ticket"]
|
tok = t.json()["data"]["ticket"]
|
||||||
bad = []
|
bad = []
|
||||||
with httpx.Client(timeout=15, verify=False) as c:
|
with httpx.Client(timeout=15, verify=False) as c:
|
||||||
for vmid in VMIDS:
|
for vmid in VMIDS:
|
||||||
r = c.get(
|
r = c.get(f"{PVE}/nodes/{PVE_NODE}/qemu/{vmid}/status/current", headers={"Cookie": f"PVEAuthCookie={tok}"})
|
||||||
f"{PVE}/nodes/{PVE_NODE}/qemu/{vmid}/status/current",
|
|
||||||
headers={"Cookie": f"PVEAuthCookie={tok}"},
|
|
||||||
)
|
|
||||||
st = r.json().get("data", {}).get("status") if r.status_code == 200 else "error"
|
st = r.json().get("data", {}).get("status") if r.status_code == 200 else "error"
|
||||||
if st != "running":
|
if st != "running": bad.append({"vmid": vmid, "status": st})
|
||||||
bad.append({"vmid": vmid, "status": st})
|
if not bad: return []
|
||||||
if not bad:
|
return [{"severity":"critical","category":"infra","title":f"VMs paradas {bad}","detail_md":str(bad),"evidence":{"bad":bad},"human_action":"qm start no big1"}]
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"severity": "critical",
|
|
||||||
"category": "infra",
|
|
||||||
"title": f"VMs paradas {bad}",
|
|
||||||
"detail_md": str(bad),
|
|
||||||
"evidence": {"bad": bad},
|
|
||||||
"human_action": "qm start no big1",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [
|
return [{"severity":"info","category":"infra","title":"Proxmox check erro","detail_md":str(e),"evidence":{},"human_action":""}]
|
||||||
{
|
|
||||||
"severity": "info",
|
|
||||||
"category": "infra",
|
|
||||||
"title": "Proxmox check erro",
|
|
||||||
"detail_md": str(e),
|
|
||||||
"evidence": {},
|
|
||||||
"human_action": "",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def check_ollama_vm123():
|
def check_ollama_vm123():
|
||||||
r = _http(f"{OLLAMA}/api/tags", max_ms=5000)
|
r = _http(f"{OLLAMA}/api/tags", max_ms=5000)
|
||||||
return [] if r["ok"] else [
|
return [] if r["ok"] else [{"severity":"high","category":"infra","title":"Ollama VM123 offline","detail_md":str(r),"evidence":r,"human_action":"systemctl start ollama VM123"}]
|
||||||
{
|
|
||||||
"severity": "high",
|
|
||||||
"category": "infra",
|
|
||||||
"title": "Ollama VM123 offline",
|
|
||||||
"detail_md": str(r),
|
|
||||||
"evidence": r,
|
|
||||||
"human_action": "systemctl start ollama VM123",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def check_vm123_finance_stack():
|
|
||||||
out = []
|
|
||||||
foss = _http(f"http://{VM123_IP}:8092/", max_ms=5000)
|
|
||||||
if not foss["ok"]:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"severity": "high",
|
|
||||||
"category": "api",
|
|
||||||
"title": "FOSSBilling VM123 down",
|
|
||||||
"detail_md": str(foss),
|
|
||||||
"evidence": foss,
|
|
||||||
"human_action": "docker compose VM123 finance stack",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
odoo = _http(f"http://{VM123_IP}:8069/web/login", max_ms=5000)
|
|
||||||
if not odoo["ok"]:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "api",
|
|
||||||
"title": "Odoo VM123 inacessível",
|
|
||||||
"detail_md": str(odoo),
|
|
||||||
"evidence": odoo,
|
|
||||||
"human_action": "Verificar container Odoo VM123",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def check_vm123_openpanel_bridge():
|
|
||||||
r = _http(f"{OPENPANEL_BRIDGE}/health", max_ms=4000)
|
|
||||||
if r.get("status_code") == 404:
|
|
||||||
r = _http(OPENPANEL_BRIDGE, max_ms=4000)
|
|
||||||
return [] if r["ok"] else [
|
|
||||||
{
|
|
||||||
"severity": "warn",
|
|
||||||
"category": "api",
|
|
||||||
"title": "OpenPanel bridge VM123 falhou",
|
|
||||||
"detail_md": str(r),
|
|
||||||
"evidence": r,
|
|
||||||
"human_action": f"Bridge {OPENPANEL_BRIDGE}",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
SCENARIO_RUNNERS = {
|
SCENARIO_RUNNERS = {
|
||||||
"desk.api.health": lambda conn, **kw: check_desk_api_health(),
|
"desk.api.health": lambda conn, **kw: check_desk_api_health(),
|
||||||
"wizard.vm112.bundle": lambda conn, **kw: check_vm112_health(),
|
"wizard.vm112.bundle": lambda conn, **kw: check_vm112_health(),
|
||||||
"pfsense.api.system": lambda conn, **kw: check_pfsense_api(),
|
"pfsense.api.system": lambda conn, **kw: check_pfsense_api(),
|
||||||
"funnel.stuck.onboarding": lambda conn, **kw: check_funnel_stuck(conn),
|
"funnel.stuck.onboarding": lambda conn, **kw: check_funnel_stuck(conn),
|
||||||
"integration.webhook.gap": lambda conn, **kw: check_integration_gap(
|
"integration.webhook.gap": lambda conn, **kw: check_integration_gap(kw.get("ops_api_url",""), kw.get("internal_token","")),
|
||||||
kw.get("ops_api_url", ""), kw.get("internal_token", "")
|
|
||||||
),
|
|
||||||
"proxmox.cluster": lambda conn, **kw: check_proxmox_cluster(),
|
"proxmox.cluster": lambda conn, **kw: check_proxmox_cluster(),
|
||||||
"ollama.vm123.health": lambda conn, **kw: check_ollama_vm123(),
|
"ollama.vm123.health": lambda conn, **kw: check_ollama_vm123(),
|
||||||
"vm123.finance.stack": lambda conn, **kw: check_vm123_finance_stack(),
|
|
||||||
"vm123.openpanel.bridge": lambda conn, **kw: check_vm123_openpanel_bridge(),
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
"""Ollama VM123 + fallback — Spec 029 T0/T1."""
|
"""Ollama VM123 + fallback — Spec 029 T1."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
|
||||||
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
|
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
|
||||||
AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
|
|
||||||
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
|
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
def ollama_available() -> bool:
|
def ollama_available() -> bool:
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=3.0) as c:
|
with httpx.Client(timeout=3.0) as c:
|
||||||
|
|
@ -18,70 +14,25 @@ def ollama_available() -> bool:
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def advise_human_action(*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None) -> tuple[str, str]:
|
||||||
def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> tuple[str, str]:
|
prompt = (
|
||||||
if not AGENTIC_LLM_ENABLED or not ollama_available():
|
"Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n"
|
||||||
return ("", "t0")
|
f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
|
||||||
messages = []
|
|
||||||
if system:
|
|
||||||
messages.append({"role": "system", "content": system})
|
|
||||||
messages.append({"role": "user", "content": prompt})
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=120.0) as c:
|
|
||||||
r = c.post(
|
|
||||||
f"{OLLAMA_BASE_URL}/api/chat",
|
|
||||||
json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": False},
|
|
||||||
)
|
)
|
||||||
|
if not AGENTIC_LLM_ENABLED:
|
||||||
|
return (f"Investigar manualmente: {finding_title}", "t0")
|
||||||
|
if ollama_available():
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=90.0) as c:
|
||||||
|
r = c.post(f"{OLLAMA_BASE_URL}/api/chat", json={
|
||||||
|
"model": AGENTIC_LLM_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"stream": False,
|
||||||
|
})
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
txt = (r.json().get("message") or {}).get("content", "").strip()
|
txt = (r.json().get("message") or {}).get("content", "").strip()
|
||||||
if txt:
|
if txt:
|
||||||
return txt, AGENTIC_LLM_MODEL
|
return txt, AGENTIC_LLM_MODEL
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return ("", "t0-fallback")
|
return (f"Rever logs e specs para: {finding_title}", "t0-fallback")
|
||||||
|
|
||||||
|
|
||||||
def advise_human_action(
|
|
||||||
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
prompt = (
|
|
||||||
"Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n"
|
|
||||||
f"Problema: {finding_title}\nDetalhe: {finding_detail}\n"
|
|
||||||
f"KB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
|
|
||||||
)
|
|
||||||
txt, model = _chat(prompt)
|
|
||||||
if txt:
|
|
||||||
return txt, model
|
|
||||||
return (f"Investigar manualmente: {finding_title}", "t0")
|
|
||||||
|
|
||||||
|
|
||||||
def chat_context(
|
|
||||||
*,
|
|
||||||
question: str,
|
|
||||||
kb_snippets: list[str] | None = None,
|
|
||||||
findings_summary: str | None = None,
|
|
||||||
user_role: str = "technician",
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""T1 — resposta contextual para janela Desk / bot interno."""
|
|
||||||
system = (
|
|
||||||
"És o copiloto Agentic Ops da Ligbox (VM112 wizard, VM122 Desk, VM123 finance). "
|
|
||||||
"Responde em português BR, objectivo, com passos acionáveis. "
|
|
||||||
"Nunca inventes credenciais. Se não souberes, diz o que verificar."
|
|
||||||
)
|
|
||||||
ctx = []
|
|
||||||
if findings_summary:
|
|
||||||
ctx.append(f"Findings abertos:\n{findings_summary[:2000]}")
|
|
||||||
if kb_snippets:
|
|
||||||
ctx.append("KB:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
|
|
||||||
prompt = (
|
|
||||||
f"Perfil utilizador: {user_role}\n"
|
|
||||||
f"Contexto ops:\n{chr(10).join(ctx) or 'N/A'}\n\n"
|
|
||||||
f"Pergunta: {question}"
|
|
||||||
)
|
|
||||||
txt, model = _chat(prompt, system=system)
|
|
||||||
if txt:
|
|
||||||
return txt, model
|
|
||||||
return (
|
|
||||||
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
|
|
||||||
"t0",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -16,7 +16,5 @@ def load_registry() -> list[dict]:
|
||||||
{"id": "funnel.stuck.onboarding", "title": "Funil travado", "severity_default": "warn"},
|
{"id": "funnel.stuck.onboarding", "title": "Funil travado", "severity_default": "warn"},
|
||||||
{"id": "integration.webhook.gap", "title": "Gap webhook VM112", "severity_default": "high"},
|
{"id": "integration.webhook.gap", "title": "Gap webhook VM112", "severity_default": "high"},
|
||||||
{"id": "proxmox.cluster", "title": "Proxmox VMs críticas", "severity_default": "critical"},
|
{"id": "proxmox.cluster", "title": "Proxmox VMs críticas", "severity_default": "critical"},
|
||||||
{"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high", "agent_id": "sentinel"},
|
{"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high"},
|
||||||
{"id": "vm123.finance.stack", "title": "VM123 Finance Stack", "severity_default": "high", "agent_id": "sentinel"},
|
|
||||||
{"id": "vm123.openpanel.bridge", "title": "OpenPanel Bridge VM123", "severity_default": "warn", "agent_id": "sentinel"},
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,237 +1,63 @@
|
||||||
"""Agentic API — Spec 029."""
|
"""Agentic API — Spec 029."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app import auth
|
from app import auth
|
||||||
from app.agents import llm_client, runner, store
|
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"])
|
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||||
|
|
||||||
|
|
||||||
def _db():
|
def _db():
|
||||||
conn = auth.db()
|
conn = auth.db()
|
||||||
try:
|
try: yield conn
|
||||||
yield conn
|
finally: conn.close()
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _ops_view(user):
|
def _ops_view(user):
|
||||||
if user.role not in (
|
if user.role not in ("super_admin","ops_lead","technician","noc","agentic_operator"):
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"noc",
|
|
||||||
"agentic_operator",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"security_analyst",
|
|
||||||
):
|
|
||||||
raise HTTPException(403, "insufficient permissions")
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
|
||||||
|
|
||||||
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")
|
@router.get("/health")
|
||||||
def agents_health():
|
def agents_health():
|
||||||
return {
|
return {"status":"ok","tier":"t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
|
||||||
"status": "ok",
|
"ollama": llm_client.ollama_available(), "ollama_url": llm_client.OLLAMA_BASE_URL,
|
||||||
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
|
"model": llm_client.AGENTIC_LLM_MODEL}
|
||||||
"ollama": llm_client.ollama_available(),
|
|
||||||
"ollama_url": llm_client.OLLAMA_BASE_URL,
|
|
||||||
"model": llm_client.AGENTIC_LLM_MODEL,
|
|
||||||
"embed_model": llm_client.AGENTIC_EMBED_MODEL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@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")
|
@router.get("/scenarios")
|
||||||
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
_ops_view(user)
|
_ops_view(user); runner.sync_registry(conn); conn.commit()
|
||||||
runner.sync_registry(conn)
|
|
||||||
conn.commit()
|
|
||||||
return {"scenarios": store.list_scenarios(conn)}
|
return {"scenarios": store.list_scenarios(conn)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/findings")
|
@router.get("/findings")
|
||||||
def list_findings(
|
def list_findings(user=Depends(auth.get_current_user), conn=Depends(_db), severity: str|None=None, limit: int=Query(50, ge=1, le=200), open_only: bool=True):
|
||||||
user=Depends(auth.get_current_user),
|
|
||||||
conn=Depends(_db),
|
|
||||||
severity: str | None = None,
|
|
||||||
limit: int = Query(50, ge=1, le=200),
|
|
||||||
open_only: bool = True,
|
|
||||||
):
|
|
||||||
_ops_view(user)
|
_ops_view(user)
|
||||||
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
|
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/findings/{finding_id}/ack")
|
@router.post("/findings/{finding_id}/ack")
|
||||||
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
_ops_view(user)
|
_ops_view(user)
|
||||||
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
|
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
|
||||||
raise HTTPException(404, "not found")
|
raise HTTPException(404, "not found")
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
conn.execute(
|
conn.execute("UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", (now, user.username, finding_id))
|
||||||
"UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?",
|
|
||||||
(now, user.username, finding_id),
|
|
||||||
)
|
|
||||||
store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username})
|
store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username})
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "id": finding_id}
|
return {"ok": True, "id": finding_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/action-log")
|
@router.get("/action-log")
|
||||||
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)):
|
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int=Query(100, ge=1, le=500)):
|
||||||
_ops_view(user)
|
_ops_view(user)
|
||||||
return {"events": store.list_action_log(conn, limit=limit)}
|
return {"events": store.list_action_log(conn, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/runs/{scenario_id}")
|
@router.post("/runs/{scenario_id}")
|
||||||
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
if user.role not in ("super_admin", "ops_lead", "agentic_operator"):
|
if user.role not in ("super_admin","ops_lead"): raise HTTPException(403, "insufficient permissions")
|
||||||
raise HTTPException(403, "insufficient permissions")
|
|
||||||
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
|
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
|
||||||
conn.commit()
|
conn.commit(); return r
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
|
||||||
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
|
||||||
"""Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
|
|
||||||
_ops_view(user)
|
|
||||||
kb = store.search_kb(conn, body.question)
|
|
||||||
findings_summary = ""
|
|
||||||
if body.include_findings:
|
|
||||||
open_f = store.list_findings(conn, limit=8, open_only=True)
|
|
||||||
if open_f:
|
|
||||||
findings_summary = "\n".join(
|
|
||||||
f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f
|
|
||||||
)
|
|
||||||
answer, model = llm_client.chat_context(
|
|
||||||
question=body.question,
|
|
||||||
kb_snippets=[k["snippet"] for k in kb],
|
|
||||||
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=body.target_agent,
|
|
||||||
payload={"user": user.username, "model": model, "thread_id": thread_id},
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/internal/tick")
|
@router.post("/internal/tick")
|
||||||
def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)):
|
def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)):
|
||||||
kb = runner.index_specs_kb(conn)
|
kb = runner.index_specs_kb(conn)
|
||||||
result = runner.run_all_enabled(conn, trigger="cron")
|
result = runner.run_all_enabled(conn, trigger="cron")
|
||||||
store.log_event(
|
store.log_event(conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", payload={"kb": kb, **result})
|
||||||
conn,
|
|
||||||
event_type="tick.complete",
|
|
||||||
message=f"kb={kb} runs={result['total']}",
|
|
||||||
agent_id="A0",
|
|
||||||
payload={"kb": kb, **result},
|
|
||||||
)
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "kb_indexed": kb, **result}
|
return {"ok": True, "kb_indexed": kb, **result}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ from __future__ import annotations
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.agents import checks, llm_client, notify, registry, store
|
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"))
|
SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs"))
|
||||||
OPS_API = os.getenv("OPS_API_URL", "http://api:8080")
|
OPS_API = os.getenv("OPS_API_URL", "http://api:8080")
|
||||||
|
|
@ -30,14 +28,11 @@ def index_specs_kb(conn):
|
||||||
|
|
||||||
def run_scenario(conn, scenario_id, *, trigger="cron"):
|
def run_scenario(conn, scenario_id, *, trigger="cron"):
|
||||||
sc = store.get_scenario(conn, scenario_id)
|
sc = store.get_scenario(conn, scenario_id)
|
||||||
if not sc:
|
if not sc: return {"ok": False, "error": "not found"}
|
||||||
return {"ok": False, "error": "not found"}
|
|
||||||
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)
|
fn = checks.SCENARIO_RUNNERS.get(scenario_id)
|
||||||
if not fn:
|
if not fn: return {"ok": False, "error": "no runner"}
|
||||||
return {"ok": False, "error": "no runner"}
|
|
||||||
run_id = store.create_run(conn, scenario_id, trigger)
|
run_id = store.create_run(conn, scenario_id, trigger)
|
||||||
store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id, agent_id=agent_id)
|
store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id)
|
||||||
raw = fn(conn, ops_api_url=OPS_API, internal_token=TOKEN)
|
raw = fn(conn, ops_api_url=OPS_API, internal_token=TOKEN)
|
||||||
fids = []
|
fids = []
|
||||||
for f in raw:
|
for f in raw:
|
||||||
|
|
@ -53,23 +48,12 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
|
||||||
category=f.get("category","api"), title=f.get("title","Finding"), detail_md=f.get("detail_md",""),
|
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])
|
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
|
||||||
fids.append(fid)
|
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"):
|
if f.get("severity") in ("high", "critical"):
|
||||||
notify.notify_finding({**f, "suggested_human_action": human})
|
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)
|
store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid})
|
||||||
status = "ok" if not raw else "degraded"
|
status = "ok" if not raw else "degraded"
|
||||||
store.finish_run(conn, run_id, status=status, summary=f"{len(raw)} finding(s)" if raw else "healthy")
|
store.finish_run(conn, run_id, status=status, summary=f"{len(raw)} finding(s)" if raw else "healthy")
|
||||||
store.log_event(conn, event_type="run.finish", message=status, run_id=run_id, agent_id=agent_id)
|
store.log_event(conn, event_type="run.finish", message=status, run_id=run_id)
|
||||||
conn.commit()
|
|
||||||
return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids}
|
return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids}
|
||||||
|
|
||||||
def run_all_enabled(conn, trigger="cron"):
|
def run_all_enabled(conn, trigger="cron"):
|
||||||
|
|
|
||||||
|
|
@ -3,36 +3,21 @@ scenarios:
|
||||||
- id: desk.api.health
|
- id: desk.api.health
|
||||||
title: Desk VM122 API
|
title: Desk VM122 API
|
||||||
severity_default: high
|
severity_default: high
|
||||||
agent_id: sentinel
|
|
||||||
- id: wizard.vm112.bundle
|
- id: wizard.vm112.bundle
|
||||||
title: VM112 Wizard
|
title: VM112 Wizard
|
||||||
severity_default: high
|
severity_default: high
|
||||||
agent_id: A1
|
|
||||||
- id: pfsense.api.system
|
- id: pfsense.api.system
|
||||||
title: pfSense API
|
title: pfSense API
|
||||||
severity_default: warn
|
severity_default: warn
|
||||||
agent_id: A2
|
|
||||||
- id: funnel.stuck.onboarding
|
- id: funnel.stuck.onboarding
|
||||||
title: Funil travado
|
title: Funil travado
|
||||||
severity_default: warn
|
severity_default: warn
|
||||||
agent_id: A6
|
|
||||||
- id: integration.webhook.gap
|
- id: integration.webhook.gap
|
||||||
title: Gap webhook VM112
|
title: Gap webhook VM112
|
||||||
severity_default: high
|
severity_default: high
|
||||||
agent_id: sentinel
|
|
||||||
- id: proxmox.cluster
|
- id: proxmox.cluster
|
||||||
title: Proxmox VMs críticas
|
title: Proxmox VMs críticas
|
||||||
severity_default: critical
|
severity_default: critical
|
||||||
agent_id: A1
|
|
||||||
- id: ollama.vm123.health
|
- id: ollama.vm123.health
|
||||||
title: Ollama VM123
|
title: Ollama VM123
|
||||||
severity_default: high
|
severity_default: high
|
||||||
agent_id: sentinel
|
|
||||||
- id: vm123.finance.stack
|
|
||||||
title: VM123 Finance Stack
|
|
||||||
severity_default: high
|
|
||||||
agent_id: sentinel
|
|
||||||
- id: vm123.openpanel.bridge
|
|
||||||
title: OpenPanel Bridge VM123
|
|
||||||
severity_default: warn
|
|
||||||
agent_id: sentinel
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
"""Persistence Agentic Ops."""
|
"""Persistence Agentic Ops."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json, sqlite3, time
|
import json, sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.agents.messages import init_messages_schema
|
|
||||||
|
|
||||||
def _now():
|
def _now():
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
def _exec_retry(conn, sql, params=(), *, attempts=8):
|
|
||||||
for attempt in range(attempts):
|
|
||||||
try:
|
|
||||||
return conn.execute(sql, params)
|
|
||||||
except sqlite3.OperationalError as exc:
|
|
||||||
if "locked" not in str(exc).lower() or attempt >= attempts - 1:
|
|
||||||
raise
|
|
||||||
time.sleep(0.25 * (attempt + 1))
|
|
||||||
|
|
||||||
def init_agent_schema(conn):
|
def init_agent_schema(conn):
|
||||||
init_messages_schema(conn)
|
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS agent_scenarios (
|
CREATE TABLE IF NOT EXISTS agent_scenarios (
|
||||||
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,
|
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,
|
||||||
|
|
@ -99,12 +87,11 @@ def list_action_log(conn, limit=100):
|
||||||
return [dict(r) for r in conn.execute("SELECT * FROM agent_action_log ORDER BY id DESC LIMIT ?", (limit,))]
|
return [dict(r) for r in conn.execute("SELECT * FROM agent_action_log ORDER BY id DESC LIMIT ?", (limit,))]
|
||||||
|
|
||||||
def index_kb_file(conn, source_path, text):
|
def index_kb_file(conn, source_path, text):
|
||||||
_exec_retry(conn, "DELETE FROM agent_kb_chunks WHERE source_path=?", (source_path,))
|
conn.execute("DELETE FROM agent_kb_chunks WHERE source_path=?", (source_path,))
|
||||||
now = _now()
|
now = _now()
|
||||||
for i in range(0, len(text), 1200):
|
for i in range(0, len(text), 1200):
|
||||||
_exec_retry(conn, "INSERT INTO agent_kb_chunks (source_path,chunk_text,indexed_at) VALUES (?,?,?)",
|
conn.execute("INSERT INTO agent_kb_chunks (source_path,chunk_text,indexed_at) VALUES (?,?,?)",
|
||||||
(source_path, text[i:i+1200], now))
|
(source_path, text[i:i+1200], now))
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def search_kb(conn, query, limit=8):
|
def search_kb(conn, query, limit=8):
|
||||||
terms = [t.strip().lower() for t in query.split() if len(t.strip()) > 2]
|
terms = [t.strip().lower() for t in query.split() if len(t.strip()) > 2]
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ from typing import Any
|
||||||
from fastapi import Depends, Header, HTTPException, Request
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
import bcrypt
|
import bcrypt
|
||||||
import time
|
|
||||||
|
|
||||||
from app.totp_util import verify_code as verify_totp_code
|
from app.totp_util import verify_code as verify_totp_code
|
||||||
|
|
||||||
|
|
@ -56,7 +55,7 @@ def db() -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=60000")
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -189,19 +188,12 @@ def check_credentials(username: str, password: str) -> tuple[DeskUser | None, sq
|
||||||
|
|
||||||
def touch_last_login(username: str) -> None:
|
def touch_last_login(username: str) -> None:
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
for attempt in range(8):
|
|
||||||
try:
|
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
|
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
|
||||||
(now, now, username),
|
(now, now, username),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return
|
|
||||||
except sqlite3.OperationalError as exc:
|
|
||||||
if "locked" not in str(exc).lower() or attempt >= 7:
|
|
||||||
raise
|
|
||||||
time.sleep(0.25 * (attempt + 1))
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(username: str, password: str) -> DeskUser | None:
|
def authenticate_user(username: str, password: str) -> DeskUser | None:
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ from app.billing_routes import router as billing_router
|
||||||
from app.security_routes import router as security_router
|
from app.security_routes import router as security_router
|
||||||
from app.infra_stack_routes import router as infra_stack_router
|
from app.infra_stack_routes import router as infra_stack_router
|
||||||
from app.vm123.routes import router as vm123_router
|
from app.vm123.routes import router as vm123_router
|
||||||
from app.agents.routes import router as agents_router
|
|
||||||
from app.agents.store import init_agent_schema
|
|
||||||
from app.collectors.base import run_audit
|
from app.collectors.base import run_audit
|
||||||
from app.permissions import (
|
from app.permissions import (
|
||||||
can_assign_ticket,
|
can_assign_ticket,
|
||||||
|
|
@ -119,7 +117,7 @@ ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.as
|
||||||
|
|
||||||
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
|
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
|
||||||
|
|
||||||
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic")
|
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.0-desk-assist")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(registration_router)
|
app.include_router(registration_router)
|
||||||
|
|
@ -135,7 +133,6 @@ app.include_router(migration_router)
|
||||||
app.include_router(billing_router)
|
app.include_router(billing_router)
|
||||||
app.include_router(infra_stack_router)
|
app.include_router(infra_stack_router)
|
||||||
app.include_router(vm123_router)
|
app.include_router(vm123_router)
|
||||||
app.include_router(agents_router)
|
|
||||||
|
|
||||||
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
|
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
|
||||||
|
|
||||||
|
|
@ -145,7 +142,7 @@ def db():
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=60000")
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -188,9 +185,8 @@ def init_db():
|
||||||
|
|
||||||
init_purge_jobs_schema(conn)
|
init_purge_jobs_schema(conn)
|
||||||
init_purge_auth_schema(conn)
|
init_purge_auth_schema(conn)
|
||||||
init_agent_schema(conn)
|
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=60000")
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -827,7 +823,7 @@ def startup():
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
redis.from_url(REDIS_URL).ping()
|
redis.from_url(REDIS_URL).ping()
|
||||||
return {"status": "ok", "service": "ligbox-ops-api", "version": app.version}
|
return {"status": "ok", "service": "ligbox-ops-api", "version": "0.9.6-spec019-023"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/integrations")
|
@app.get("/api/v1/integrations")
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,6 @@ MODULES: tuple[ModuleDef, ...] = (
|
||||||
description="Painel visual SOC VM112→VM122.",
|
description="Painel visual SOC VM112→VM122.",
|
||||||
nav_views=("infra2",),
|
nav_views=("infra2",),
|
||||||
),
|
),
|
||||||
ModuleDef(
|
|
||||||
id="agentic-ops",
|
|
||||||
label="Agentic Ops",
|
|
||||||
description="Vigilância 24/7, findings, advisor IA e contexto ops (Spec 029).",
|
|
||||||
nav_views=("agentic-ops",),
|
|
||||||
default_enabled=True,
|
|
||||||
),
|
|
||||||
ModuleDef(
|
ModuleDef(
|
||||||
id="funnel-timing",
|
id="funnel-timing",
|
||||||
label="Relógio por fase",
|
label="Relógio por fase",
|
||||||
|
|
@ -142,29 +135,6 @@ MODULES: tuple[ModuleDef, ...] = (
|
||||||
|
|
||||||
MODULE_BY_ID = {m.id: m for m in MODULES}
|
MODULE_BY_ID = {m.id: m for m in MODULES}
|
||||||
|
|
||||||
# Spec 027 + 029 — módulos ON por defeito na activação
|
|
||||||
ROLE_MODULE_DEFAULTS: dict[str, frozenset[str]] = {
|
|
||||||
"sales_admin": frozenset(
|
|
||||||
{"core", "leads", "funnel-timing", "overview-home", "billing-recurrence", "tenants"}
|
|
||||||
),
|
|
||||||
"sales_support": frozenset({"core", "leads", "funnel-timing", "overview-home", "tenants"}),
|
|
||||||
"finance": frozenset({"core", "overview-home", "billing-recurrence", "events"}),
|
|
||||||
"marketing": frozenset({"core", "leads", "funnel-timing", "overview-home"}),
|
|
||||||
"seo": frozenset({"core", "funnel-timing", "overview-home", "leads"}),
|
|
||||||
"developer": frozenset({"core", "events", "infra", "overview", "agentic-ops"}),
|
|
||||||
"devops": frozenset({"core", "infra", "infra2-soc", "overview-home", "events", "agentic-ops"}),
|
|
||||||
"security_analyst": frozenset({"core", "infra2-soc", "wazuh-soc", "events", "agentic-ops"}),
|
|
||||||
"content_editor": frozenset({"core"}),
|
|
||||||
"agentic_operator": frozenset({"core", "overview", "events", "infra2-soc", "agentic-ops"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def role_module_defaults(role: str) -> frozenset[str] | None:
|
|
||||||
"""None = roles ops legacy (003) — respeitam só toggles globais."""
|
|
||||||
if role in ("super_admin", "ops_lead", "technician", "noc"):
|
|
||||||
return None
|
|
||||||
return ROLE_MODULE_DEFAULTS.get(role, frozenset({"core"}))
|
|
||||||
|
|
||||||
|
|
||||||
def all_module_ids() -> list[str]:
|
def all_module_ids() -> list[str]:
|
||||||
return [m.id for m in MODULES]
|
return [m.id for m in MODULES]
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,19 @@
|
||||||
"""RBAC helpers for Ligbox Ops Desk — Spec 003 + 027."""
|
"""RBAC helpers for Ligbox Ops Desk."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Ops (Spec 003)
|
ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
|
||||||
OPS_ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
|
|
||||||
|
|
||||||
# Comercial (Spec 027)
|
ROLE_LABELS = {
|
||||||
SALES_ROLES = frozenset({"sales_admin", "sales_support"})
|
|
||||||
|
|
||||||
# Negócio / plataforma (Spec 027)
|
|
||||||
BUSINESS_ROLES = frozenset(
|
|
||||||
{
|
|
||||||
"finance",
|
|
||||||
"marketing",
|
|
||||||
"seo",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"security_analyst",
|
|
||||||
"content_editor",
|
|
||||||
"agentic_operator",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sistema (não humanos)
|
|
||||||
SYSTEM_ROLES = frozenset({"api_service", "agent_system"})
|
|
||||||
|
|
||||||
ALL_ROLES = OPS_ROLES | SALES_ROLES | BUSINESS_ROLES | SYSTEM_ROLES
|
|
||||||
|
|
||||||
# Funções humanas (login Desk)
|
|
||||||
HUMAN_ROLES = OPS_ROLES | SALES_ROLES | BUSINESS_ROLES
|
|
||||||
|
|
||||||
# Atribuíveis no cadastro Spec 004 (exceto super_admin)
|
|
||||||
ASSIGNABLE_ROLES = HUMAN_ROLES - {"super_admin"}
|
|
||||||
|
|
||||||
# Compatibilidade com código existente
|
|
||||||
ROLES = HUMAN_ROLES
|
|
||||||
|
|
||||||
ROLE_LABELS: dict[str, str] = {
|
|
||||||
"super_admin": "Super Admin",
|
"super_admin": "Super Admin",
|
||||||
"ops_lead": "Chefe Ops",
|
"ops_lead": "Chefe Ops",
|
||||||
"technician": "Suporte",
|
"technician": "Suporte",
|
||||||
"noc": "NOC",
|
"noc": "NOC",
|
||||||
"sales_admin": "Sales Admin",
|
|
||||||
"sales_support": "Sales Support",
|
|
||||||
"finance": "Financeiro",
|
|
||||||
"marketing": "Marketing",
|
|
||||||
"seo": "SEO",
|
|
||||||
"developer": "Developer",
|
|
||||||
"devops": "DevOps",
|
|
||||||
"security_analyst": "Segurança / SOC",
|
|
||||||
"content_editor": "Conteúdo / CMS",
|
|
||||||
"agentic_operator": "Operador Agentes IA",
|
|
||||||
"api_service": "API Service",
|
|
||||||
"agent_system": "Agent System",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_valid_role(role: str) -> bool:
|
|
||||||
return role in ALL_ROLES
|
|
||||||
|
|
||||||
|
|
||||||
def is_assignable_role(role: str) -> bool:
|
|
||||||
return role in ASSIGNABLE_ROLES
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_tickets(role: str) -> bool:
|
def can_read_tickets(role: str) -> bool:
|
||||||
return role in HUMAN_ROLES
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
def can_patch_ticket(role: str, ticket: dict, username: str) -> bool:
|
def can_patch_ticket(role: str, ticket: dict, username: str) -> bool:
|
||||||
|
|
@ -90,99 +38,37 @@ def can_run_audit(role: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def can_read_audit_overview(role: str) -> bool:
|
def can_read_audit_overview(role: str) -> bool:
|
||||||
return role in (
|
return role in ("super_admin", "ops_lead", "noc")
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"noc",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"security_analyst",
|
|
||||||
"agentic_operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_audit_scorecard(role: str) -> bool:
|
def can_read_audit_scorecard(role: str) -> bool:
|
||||||
return role in (
|
return role in ("super_admin", "ops_lead", "noc")
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"noc",
|
|
||||||
"developer",
|
|
||||||
"security_analyst",
|
|
||||||
"agentic_operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_cloudflare_dns(role: str) -> bool:
|
def can_read_cloudflare_dns(role: str) -> bool:
|
||||||
return role in (
|
return role in ("super_admin", "ops_lead", "technician", "noc")
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"noc",
|
|
||||||
"seo",
|
|
||||||
"devops",
|
|
||||||
"developer",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_funnel(role: str) -> bool:
|
def can_read_funnel(role: str) -> bool:
|
||||||
return role in (
|
return role in ROLES
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"noc",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
"finance",
|
|
||||||
"marketing",
|
|
||||||
"seo",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"agentic_operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_session_timeline(role: str) -> bool:
|
def can_read_session_timeline(role: str) -> bool:
|
||||||
return role in (
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
"finance",
|
|
||||||
"marketing",
|
|
||||||
"seo",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"agentic_operator",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_list_webhook_events(role: str, source: str | None = None) -> bool:
|
def can_list_webhook_events(role: str, source: str | None = None) -> bool:
|
||||||
if role == "noc":
|
if role == "noc":
|
||||||
return source in (None, "wazuh", "vm112-security")
|
return source in (None, "wazuh", "vm112-security")
|
||||||
if role == "security_analyst":
|
return role in ROLES
|
||||||
return source in (None, "wazuh", "vm112-security", "vm112")
|
|
||||||
if role == "finance":
|
|
||||||
return source in (None, "billing", "vm112")
|
|
||||||
if role == "developer":
|
|
||||||
return source in (None, "vm112", "wazuh")
|
|
||||||
return role in HUMAN_ROLES
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_crm_leads(role: str) -> bool:
|
def can_read_crm_leads(role: str) -> bool:
|
||||||
return role in (
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
"marketing",
|
|
||||||
"seo",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_assist(role: str) -> bool:
|
def can_read_assist(role: str) -> bool:
|
||||||
return role in ("super_admin", "ops_lead", "technician", "sales_admin", "sales_support")
|
return role in ROLES
|
||||||
|
|
||||||
|
|
||||||
def can_assist_takeover(role: str) -> bool:
|
def can_assist_takeover(role: str) -> bool:
|
||||||
|
|
@ -199,15 +85,15 @@ def can_manage_users(role: str) -> bool:
|
||||||
|
|
||||||
def can_manage_vm112_domains(role: str) -> bool:
|
def can_manage_vm112_domains(role: str) -> bool:
|
||||||
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
||||||
return role in ("super_admin", "ops_lead", "devops")
|
return role in ("super_admin", "ops_lead")
|
||||||
|
|
||||||
|
|
||||||
def should_mask_sensitive(role: str) -> bool:
|
def should_mask_sensitive(role: str) -> bool:
|
||||||
return role in ("noc", "sales_support")
|
return role == "noc"
|
||||||
|
|
||||||
|
|
||||||
def can_read_migration(role: str) -> bool:
|
def can_read_migration(role: str) -> bool:
|
||||||
return role in ("super_admin", "ops_lead", "technician", "noc", "devops")
|
return role in ("super_admin", "ops_lead", "technician", "noc")
|
||||||
|
|
||||||
|
|
||||||
def can_manage_migration(role: str) -> bool:
|
def can_manage_migration(role: str) -> bool:
|
||||||
|
|
@ -215,67 +101,8 @@ def can_manage_migration(role: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def can_read_billing(role: str) -> bool:
|
def can_read_billing(role: str) -> bool:
|
||||||
return role in (
|
return role in ROLES
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"noc",
|
|
||||||
"finance",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_validate_billing(role: str) -> bool:
|
|
||||||
"""Transicionar billing_state — Spec 023 / FR-027-005 / FR-027-009."""
|
|
||||||
return role in ("super_admin", "ops_lead", "finance", "sales_admin")
|
|
||||||
|
|
||||||
|
|
||||||
def can_manage_billing(role: str) -> bool:
|
def can_manage_billing(role: str) -> bool:
|
||||||
return can_validate_billing(role)
|
return role in ("super_admin", "ops_lead")
|
||||||
|
|
||||||
|
|
||||||
def can_create_foss_order(role: str) -> bool:
|
|
||||||
return role in (
|
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"finance",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_foss_admin(role: str) -> bool:
|
|
||||||
return role in ("super_admin", "finance", "sales_admin")
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_openadmin(role: str) -> bool:
|
|
||||||
return role in ("super_admin", "devops", "sales_admin")
|
|
||||||
|
|
||||||
|
|
||||||
def can_openpanel_autologin(role: str) -> bool:
|
|
||||||
return role in (
|
|
||||||
"super_admin",
|
|
||||||
"sales_admin",
|
|
||||||
"sales_support",
|
|
||||||
"marketing",
|
|
||||||
"seo",
|
|
||||||
"content_editor",
|
|
||||||
"technician",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_openpanel_provision(role: str) -> bool:
|
|
||||||
return role in ("super_admin", "devops", "sales_admin", "sales_support")
|
|
||||||
|
|
||||||
|
|
||||||
def can_openpanel_delete(role: str) -> bool:
|
|
||||||
return role in ("super_admin", "devops")
|
|
||||||
|
|
||||||
|
|
||||||
def roles_meta() -> dict:
|
|
||||||
"""Metadados para UI — labels e funções atribuíveis no cadastro."""
|
|
||||||
return {
|
|
||||||
"labels": ROLE_LABELS,
|
|
||||||
"assignable": sorted(ASSIGNABLE_ROLES),
|
|
||||||
"human": sorted(HUMAN_ROLES),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,3 @@ python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
bcrypt==4.2.1
|
bcrypt==4.2.1
|
||||||
pyotp==2.9.0
|
pyotp==2.9.0
|
||||||
pyyaml==6.0.2
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
"""Tests Agentic Ops — Spec 029."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
from app.agents import checks, registry, store
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_has_vm123_scenarios():
|
|
||||||
scenarios = registry.load_registry()
|
|
||||||
ids = {s["id"] for s in scenarios}
|
|
||||||
assert "ollama.vm123.health" in ids
|
|
||||||
assert "vm123.finance.stack" in ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_schema_init():
|
|
||||||
conn = sqlite3.connect(":memory:")
|
|
||||||
store.init_agent_schema(conn)
|
|
||||||
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
||||||
assert "agent_findings" in tables
|
|
||||||
assert "agent_scenarios" in tables
|
|
||||||
|
|
||||||
|
|
||||||
def test_desk_health_check_returns_list():
|
|
||||||
result = checks.check_desk_api_health()
|
|
||||||
assert isinstance(result, list)
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
# Staging Agentic Ops — isolado da produção VM122
|
|
||||||
# Portas: API 8180, Frontend 8192, Redis interno
|
|
||||||
# Dados: /var/lib/ligbox-ops-platform-staging (separado)
|
|
||||||
|
|
||||||
version: "3.8"
|
|
||||||
services:
|
|
||||||
redis-staging:
|
|
||||||
image: redis:7-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru
|
|
||||||
networks: [agentic-staging]
|
|
||||||
api-staging:
|
|
||||||
build: ./api
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
SQLITE_PATH: /data/ops-staging.db
|
|
||||||
REDIS_URL: redis://redis-staging:6379/0
|
|
||||||
OPS_API_URL: http://api-staging:8080
|
|
||||||
volumes:
|
|
||||||
- /var/lib/ligbox-ops-platform-staging:/data
|
|
||||||
- ./specs:/opt/ligbox-ops-platform/specs:ro
|
|
||||||
ports:
|
|
||||||
- "10.10.10.122:8180:8080"
|
|
||||||
depends_on: [redis-staging]
|
|
||||||
networks: [agentic-staging]
|
|
||||||
worker-staging:
|
|
||||||
build: ./worker
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
OPS_API_URL: http://api-staging:8080
|
|
||||||
REDIS_URL: redis://redis-staging:6379/0
|
|
||||||
AGENTIC_INTERVAL_SEC: "600"
|
|
||||||
depends_on: [redis-staging, api-staging]
|
|
||||||
networks: [agentic-staging]
|
|
||||||
frontend-staging:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.staging
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "10.10.10.122:8192:80"
|
|
||||||
depends_on: [api-staging]
|
|
||||||
networks: [agentic-staging]
|
|
||||||
networks:
|
|
||||||
agentic-staging:
|
|
||||||
driver: bridge
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
FROM nginx:alpine
|
|
||||||
COPY index.html login.html register.html activate.html /usr/share/nginx/html/
|
|
||||||
COPY assets /usr/share/nginx/html/assets
|
|
||||||
COPY nginx.staging.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
@ -1,194 +1,32 @@
|
||||||
(function () {
|
(function () {
|
||||||
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
let state = { threadId: null, selectedAgent: 'A6' };
|
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const h = { ...(opts.headers || {}) };
|
const h = { ...(opts.headers || {}) };
|
||||||
if (!(opts.body instanceof FormData)) h['Content-Type'] = 'application/json';
|
|
||||||
const t = window.DeskAuth?.getToken?.();
|
const t = window.DeskAuth?.getToken?.();
|
||||||
if (t) h.Authorization = `Bearer ${t}`;
|
if (t) h.Authorization = `Bearer ${t}`;
|
||||||
const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h });
|
const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h });
|
||||||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentCard(a) {
|
|
||||||
const active = state.selectedAgent === a.id ? ' agentic-agent-active' : '';
|
|
||||||
return `<article class="card agentic-agent-card${active}" data-agent-id="${esc(a.id)}" tabindex="0">
|
|
||||||
<h4>${esc(a.name)} <span class="pill pill-sm">${esc(a.id)}</span></h4>
|
|
||||||
<p class="ticket-meta">${esc(a.role)}</p>
|
|
||||||
<ul class="agentic-action-list">${(a.actions || []).slice(0, 3).map(x => `<li>${esc(x)}</li>`).join('')}</ul>
|
|
||||||
<p class="ticket-meta"><strong>Aprovação:</strong> ${esc(a.approval)}</p>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inboxRow(m) {
|
|
||||||
return `<article class="card agentic-inbox-item" data-msg-id="${m.id}" data-thread-id="${m.thread_id}">
|
|
||||||
<div class="agentic-inbox-head">
|
|
||||||
<strong>${esc(m.agent_name || m.from_id)}</strong>
|
|
||||||
<span class="pill">${esc(m.thread_severity || 'info')}</span>
|
|
||||||
</div>
|
|
||||||
<p class="ticket-meta">${esc(m.thread_subject || m.message)} · ${esc(m.created_at)}</p>
|
|
||||||
<p class="agentic-inbox-body">${esc((m.body || '').slice(0, 280))}</p>
|
|
||||||
<div class="agentic-inbox-actions">
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-open-thread="${m.thread_id}">Abrir conversa</button>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" data-ack-msg="${m.id}">Arquivar</button>
|
|
||||||
</div>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadBubble(m) {
|
|
||||||
const isHuman = m.from_type === 'human';
|
|
||||||
const cls = isHuman ? 'agentic-bubble-human' : 'agentic-bubble-agent';
|
|
||||||
return `<div class="agentic-bubble ${cls}">
|
|
||||||
<div class="agentic-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}</div>
|
|
||||||
<div class="agentic-bubble-body">${esc(m.body).replace(/\n/g, '<br>')}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadThread(el, threadId) {
|
|
||||||
state.threadId = threadId;
|
|
||||||
const box = el.querySelector('#agentic-thread-messages');
|
|
||||||
if (!box) return;
|
|
||||||
box.innerHTML = '<p class="loading">Carregando thread…</p>';
|
|
||||||
const data = await api(`/threads/${threadId}/messages`);
|
|
||||||
box.innerHTML = data.messages.map(threadBubble).join('') || '<p class="empty">Sem mensagens.</p>';
|
|
||||||
box.scrollTop = box.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderAgenticOps() {
|
async function renderAgenticOps() {
|
||||||
const el = document.getElementById('agentic-ops-content');
|
const el = document.getElementById('agentic-ops-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>';
|
el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>';
|
||||||
try {
|
try {
|
||||||
const [health, roster, inbox, threads, findings] = await Promise.all([
|
const [health, scenarios, findings, log] = await Promise.all([
|
||||||
api('/health'),
|
api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'),
|
||||||
api('/roster'),
|
|
||||||
api('/inbox?limit=20'),
|
|
||||||
api('/threads?limit=15'),
|
|
||||||
api('/findings?limit=15'),
|
|
||||||
]);
|
]);
|
||||||
const tier = health.tier === 't1' ? 'T1 LLM' : 'T0';
|
const tier = health.tier === 't1' ? 'T1 LLM' : 'T0';
|
||||||
const ollama = health.ollama
|
const ollama = health.ollama ? '<span class="pill pill-ok">Ollama OK</span>' : '<span class="pill pill-warn">Ollama offline</span>';
|
||||||
? `<span class="pill pill-ok">Ollama · ${esc(health.model)}</span>`
|
const sRows = (scenarios.scenarios || []).map(s => `<tr><td><code>${esc(s.id)}</code></td><td>${esc(s.title)}</td><td>${esc(s.last_run_status||'—')}</td><td class="ticket-meta">${esc(s.last_run_at||'—')}</td></tr>`).join('');
|
||||||
: '<span class="pill pill-warn">Ollama offline</span>';
|
const fRows = (findings.findings || []).map(f => `<article class="card agentic-finding"><h3>${esc(f.title)} <span class="pill">${esc(f.severity)}</span></h3><p class="ticket-meta">${esc(f.created_at)}</p>${f.suggested_human_action?`<p><strong>Acção:</strong> ${esc(f.suggested_human_action)}</p>`:''}<button type="button" class="btn btn-ghost btn-sm" data-ack="${f.id}">Marcar visto</button></article>`).join('') || '<p class="empty">Sem findings abertos.</p>';
|
||||||
const agents = roster.agents || [];
|
const lRows = (log.events || []).map(e => `<tr><td class="ticket-meta">${esc(e.ts)}</td><td><code>${esc(e.event_type)}</code></td><td>${esc(e.message)}</td></tr>`).join('');
|
||||||
const inboxItems = inbox.messages || [];
|
el.innerHTML = `<div class="toolbar agentic-toolbar"><div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama}</p></div><button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button></div><div class="agentic-grid"><div class="card"><h3>Cenários</h3><table class="data-table"><thead><tr><th>ID</th><th>Título</th><th>Último</th><th>Quando</th></tr></thead><tbody>${sRows}</tbody></table></div><div class="agentic-findings-col"><h3>Findings</h3>${fRows}</div></div><section class="card" style="margin-top:1rem"><h3>Audit log</h3><table class="data-table data-table-compact"><thead><tr><th>Quando</th><th>Evento</th><th>Mensagem</th></tr></thead><tbody>${lRows}</tbody></table></section>`;
|
||||||
const threadOpts = (threads.threads || []).map(t =>
|
|
||||||
`<option value="${t.id}"${state.threadId === t.id ? ' selected' : ''}>#${t.id} ${esc(t.subject)} (${esc(t.agent_name)})</option>`
|
|
||||||
).join('');
|
|
||||||
const fRows = (findings.findings || []).map(f =>
|
|
||||||
`<li><strong>${esc(f.title)}</strong> <span class="pill">${esc(f.severity)}</span>
|
|
||||||
${f.suggested_human_action ? `<br><span class="ticket-meta">${esc(f.suggested_human_action)}</span>` : ''}</li>`
|
|
||||||
).join('') || '<li class="empty">Nenhum finding aberto.</li>';
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<style>
|
|
||||||
.agentic-layout{display:grid;grid-template-columns:minmax(200px,1fr) minmax(260px,1.2fr) minmax(280px,1.4fr);gap:1rem;margin-top:.5rem}
|
|
||||||
@media(max-width:1100px){.agentic-layout{grid-template-columns:1fr}}
|
|
||||||
.agentic-agent-card{cursor:pointer;border:1px solid var(--border,#333);margin-bottom:.5rem;padding:.75rem}
|
|
||||||
.agentic-agent-active{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f680}
|
|
||||||
.agentic-action-list{font-size:.85rem;margin:.4rem 0;padding-left:1.1rem;color:var(--muted,#aaa)}
|
|
||||||
.agentic-inbox-item{margin-bottom:.75rem}
|
|
||||||
.agentic-inbox-body{font-size:.9rem;margin:.35rem 0;white-space:pre-wrap}
|
|
||||||
.agentic-thread-panel{display:flex;flex-direction:column;min-height:420px}
|
|
||||||
.agentic-thread-messages{flex:1;overflow-y:auto;max-height:360px;padding:.5rem;background:rgba(0,0,0,.15);border-radius:6px;margin:.5rem 0}
|
|
||||||
.agentic-bubble{margin:.5rem 0;padding:.6rem .8rem;border-radius:8px;max-width:95%}
|
|
||||||
.agentic-bubble-agent{background:rgba(59,130,246,.12);border-left:3px solid #3b82f6}
|
|
||||||
.agentic-bubble-human{background:rgba(34,197,94,.1);border-left:3px solid #22c55e;margin-left:auto}
|
|
||||||
.agentic-bubble-meta{font-size:.75rem;color:var(--muted,#888);margin-bottom:.25rem}
|
|
||||||
.agentic-chat-box{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}
|
|
||||||
.agentic-chat-box textarea{width:100%;min-height:72px}
|
|
||||||
</style>
|
|
||||||
<div class="toolbar agentic-toolbar">
|
|
||||||
<div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama} · ${inboxItems.length} pendente(s)</p></div>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button>
|
|
||||||
</div>
|
|
||||||
<div class="agentic-layout">
|
|
||||||
<section>
|
|
||||||
<h3>Agentes (A0–A7)</h3>
|
|
||||||
<p class="ticket-meta">Clique para seleccionar destino do chat.</p>
|
|
||||||
${agents.map(agentCard).join('')}
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>Inbox operadores</h3>
|
|
||||||
<p class="ticket-meta">Mensagens dos agentes que exigem acção humana.</p>
|
|
||||||
${inboxItems.length ? inboxItems.map(inboxRow).join('') : '<p class="empty">Inbox vazia.</p>'}
|
|
||||||
<h3 style="margin-top:1rem">Findings abertos</h3>
|
|
||||||
<ul class="agentic-findings-list">${fRows}</ul>
|
|
||||||
</section>
|
|
||||||
<section class="card agentic-thread-panel">
|
|
||||||
<h3>Janela de contexto</h3>
|
|
||||||
<label class="ticket-meta">Thread
|
|
||||||
<select id="agentic-thread-select" class="input">${threadOpts || '<option value="">—</option>'}</select>
|
|
||||||
</label>
|
|
||||||
<div id="agentic-thread-messages" class="agentic-thread-messages"><p class="empty">Seleccione uma thread ou abra da inbox.</p></div>
|
|
||||||
<textarea id="agentic-reply-input" rows="2" class="input" placeholder="Responder ao agente…"></textarea>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-reply">Enviar resposta</button>
|
|
||||||
<hr style="margin:1rem 0;opacity:.3">
|
|
||||||
<h4>Chat Copiloto (${esc(state.selectedAgent)})</h4>
|
|
||||||
<div class="agentic-chat-box">
|
|
||||||
<textarea id="agentic-chat-input" rows="2" placeholder="Pergunta ao agente seleccionado…"></textarea>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-agentic-chat">Perguntar</button>
|
|
||||||
</div>
|
|
||||||
<div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div>
|
|
||||||
</section>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps);
|
el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps);
|
||||||
el.querySelectorAll('.agentic-agent-card').forEach(card => {
|
el.querySelectorAll('[data-ack]').forEach(btn => btn.addEventListener('click', async () => {
|
||||||
card.addEventListener('click', () => {
|
await api(`/findings/${btn.dataset.ack}/ack`, { method: 'POST' });
|
||||||
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();
|
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');
|
|
||||||
const q = (input?.value || '').trim();
|
|
||||||
if (!q) return;
|
|
||||||
out.hidden = false;
|
|
||||||
out.innerHTML = '<p class="loading">A pensar…</p>';
|
|
||||||
try {
|
|
||||||
const res = await api('/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }),
|
|
||||||
});
|
|
||||||
out.innerHTML = `<p><strong>${esc(state.selectedAgent)}</strong> <span class="ticket-meta">(${esc(res.model)})</span></p><p>${esc(res.answer)}</p>`;
|
|
||||||
if (res.thread_id) {
|
|
||||||
state.threadId = res.thread_id;
|
|
||||||
await loadThread(el, res.thread_id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
out.innerHTML = `<p class="error">${esc(err.message)}</p>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (state.threadId) await loadThread(el, state.threadId);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
|
el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ const views = {
|
||||||
'email-migration': document.getElementById('view-email-migration'),
|
'email-migration': document.getElementById('view-email-migration'),
|
||||||
infra: document.getElementById('view-infra'),
|
infra: document.getElementById('view-infra'),
|
||||||
infra2: document.getElementById('view-infra2'),
|
infra2: document.getElementById('view-infra2'),
|
||||||
'agentic-ops': document.getElementById('view-agentic-ops'),
|
|
||||||
messages: document.getElementById('view-messages'),
|
messages: document.getElementById('view-messages'),
|
||||||
admin: document.getElementById('view-admin'),
|
admin: document.getElementById('view-admin'),
|
||||||
account: document.getElementById('view-account'),
|
account: document.getElementById('view-account'),
|
||||||
|
|
@ -211,7 +210,6 @@ function setView(name) {
|
||||||
tenants: 'Tenants',
|
tenants: 'Tenants',
|
||||||
infra: 'INFRA CODE',
|
infra: 'INFRA CODE',
|
||||||
infra2: 'SOC — Infra 2',
|
infra2: 'SOC — Infra 2',
|
||||||
'agentic-ops': 'Agentic Ops',
|
|
||||||
messages: 'Mensagens — pedidos de cadastro',
|
messages: 'Mensagens — pedidos de cadastro',
|
||||||
admin: 'Administradores',
|
admin: 'Administradores',
|
||||||
account: 'Minha conta',
|
account: 'Minha conta',
|
||||||
|
|
@ -227,7 +225,6 @@ function setView(name) {
|
||||||
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
|
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
|
||||||
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
||||||
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
|
|
||||||
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
|
|
@ -4224,7 +4221,6 @@ async function refresh(options = {}) {
|
||||||
if (state.view === 'tenants') await renderTenants();
|
if (state.view === 'tenants') await renderTenants();
|
||||||
if (state.view === 'infra') await renderInfra();
|
if (state.view === 'infra') await renderInfra();
|
||||||
if (state.view === 'infra2') await renderInfra2();
|
if (state.view === 'infra2') await renderInfra2();
|
||||||
if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps();
|
|
||||||
if (state.view === 'messages') await renderMessages();
|
if (state.view === 'messages') await renderMessages();
|
||||||
if (state.view === 'admin') await renderAdmin();
|
if (state.view === 'admin') await renderAdmin();
|
||||||
if (state.view === 'modules') await renderModules();
|
if (state.view === 'modules') await renderModules();
|
||||||
|
|
|
||||||
|
|
@ -225,10 +225,6 @@
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
||||||
<span class="nav-label">Infra 2 <span class="nav-badge-new">SOC</span></span>
|
<span class="nav-label">Infra 2 <span class="nav-badge-new">SOC</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="agentic-ops" data-module="agentic-ops" id="nav-agentic-ops" class="nav-item nav-item-agentic">
|
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
|
|
||||||
<span class="nav-label">Agentic Ops</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-view="account" data-module="core" id="nav-account" class="nav-item nav-item-account">
|
<button type="button" data-view="account" data-module="core" id="nav-account" class="nav-item nav-item-account">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-account"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-account"/></svg></span>
|
||||||
<span class="nav-label">Minha conta</span>
|
<span class="nav-label">Minha conta</span>
|
||||||
|
|
@ -330,10 +326,6 @@
|
||||||
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
|
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-agentic-ops" class="view">
|
|
||||||
<div id="agentic-ops-content"><p class="loading">Carregando Agentic Ops…</p></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="view-account" class="view">
|
<section id="view-account" class="view">
|
||||||
<div id="account-content"><p class="loading">Carregando…</p></div>
|
<div id="account-content"><p class="loading">Carregando…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -442,8 +434,7 @@
|
||||||
<script src="/assets/desk-live-stub.js?v=20260619tickets2"></script>
|
<script src="/assets/desk-live-stub.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/servicos.js?v=20260620agentic"></script>
|
<script src="/assets/servicos.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/agentic-ops.js?v=20260620agentic"></script>
|
<script src="/assets/app.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/app.js?v=20260620agentic"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
# nginx staging — proxy API container agentic-staging
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://api-staging:8080/api/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_read_timeout 180s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@ WORKER_INTERVAL = int(os.getenv("WORKER_INTERVAL", "120"))
|
||||||
AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600"))
|
AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600"))
|
||||||
LEAD_SYNC_INTERVAL_SEC = int(os.getenv("LEAD_SYNC_INTERVAL_SEC", "900"))
|
LEAD_SYNC_INTERVAL_SEC = int(os.getenv("LEAD_SYNC_INTERVAL_SEC", "900"))
|
||||||
WEBHOOK_GAP_ALERT_MIN = int(os.getenv("WEBHOOK_GAP_ALERT_MIN", "15"))
|
WEBHOOK_GAP_ALERT_MIN = int(os.getenv("WEBHOOK_GAP_ALERT_MIN", "15"))
|
||||||
AGENTIC_INTERVAL_SEC = int(os.getenv("AGENTIC_INTERVAL_SEC", "300"))
|
|
||||||
OPS_NTFY_TOPIC = os.getenv("DESK_OPS_NTFY_TOPIC", "").strip()
|
OPS_NTFY_TOPIC = os.getenv("DESK_OPS_NTFY_TOPIC", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,29 +40,6 @@ def poll_vm112() -> None:
|
||||||
print(f"[worker] vm112 ERROR: {exc}", flush=True)
|
print(f"[worker] vm112 ERROR: {exc}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def agentic_tick(redis_client=None) -> None:
|
|
||||||
"""Spec 029 — run all agent scenarios (T0 checks + T1 advisor)."""
|
|
||||||
if not OPS_INTERNAL_TOKEN:
|
|
||||||
return
|
|
||||||
lock_key = "ops:agentic:tick:lock"
|
|
||||||
if redis_client is not None:
|
|
||||||
if not redis_client.set(lock_key, "1", nx=True, ex=900):
|
|
||||||
print("[worker] agentic tick skipped (lock held)", flush=True)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
with httpx.Client(timeout=600.0) as client:
|
|
||||||
response = client.post(
|
|
||||||
f"{OPS_API_URL}/api/v1/agents/internal/tick",
|
|
||||||
headers={"X-Ops-Internal-Token": OPS_INTERNAL_TOKEN},
|
|
||||||
)
|
|
||||||
print(f"[worker] agentic tick {response.status_code}: {response.text[:200]}", flush=True)
|
|
||||||
except Exception as exc:
|
|
||||||
print(f"[worker] agentic tick ERROR: {exc}", flush=True)
|
|
||||||
finally:
|
|
||||||
if redis_client is not None:
|
|
||||||
redis_client.delete(lock_key)
|
|
||||||
|
|
||||||
|
|
||||||
def check_integration_gap() -> None:
|
def check_integration_gap() -> None:
|
||||||
if not OPS_INTERNAL_TOKEN:
|
if not OPS_INTERNAL_TOKEN:
|
||||||
return
|
return
|
||||||
|
|
@ -107,7 +83,6 @@ def main() -> None:
|
||||||
print("[worker] started", flush=True)
|
print("[worker] started", flush=True)
|
||||||
last_audit = 0.0
|
last_audit = 0.0
|
||||||
last_lead_sync = 0.0
|
last_lead_sync = 0.0
|
||||||
last_agentic = 0.0
|
|
||||||
while True:
|
while True:
|
||||||
event = redis_client.rpop("ops:events")
|
event = redis_client.rpop("ops:events")
|
||||||
if event:
|
if event:
|
||||||
|
|
@ -121,9 +96,6 @@ def main() -> None:
|
||||||
sync_stale_leads()
|
sync_stale_leads()
|
||||||
check_integration_gap()
|
check_integration_gap()
|
||||||
last_lead_sync = now
|
last_lead_sync = now
|
||||||
if now - last_agentic >= AGENTIC_INTERVAL_SEC:
|
|
||||||
agentic_tick(redis_client)
|
|
||||||
last_agentic = now
|
|
||||||
time.sleep(WORKER_INTERVAL)
|
time.sleep(WORKER_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
# Agent Platform API — Spec 029 (completo)
|
|
||||||
|
|
||||||
**Prod:** `https://api.ops.ligbox.com.br/api/v1/agents`
|
|
||||||
**Staging:** `http://10.10.10.122:8180/api/v1/agents`
|
|
||||||
**Desk UI:** módulo `agentic-ops` · staging `:8192`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## GET /health
|
|
||||||
|
|
||||||
Público. Sem auth.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"tier": "t1",
|
|
||||||
"ollama": true,
|
|
||||||
"ollama_url": "http://10.10.10.123:11434",
|
|
||||||
"model": "qwen2.5:7b-instruct",
|
|
||||||
"embed_model": "nomic-embed-text"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## GET /roster
|
|
||||||
|
|
||||||
Auth JWT. Lista A0–A7 + Vigia + Curador com acções e aprovação.
|
|
||||||
|
|
||||||
## GET /inbox
|
|
||||||
|
|
||||||
Auth JWT. Mensagens `requires_human=1` filtradas por role Desk.
|
|
||||||
|
|
||||||
## GET /threads · GET /threads/{id}/messages
|
|
||||||
|
|
||||||
Auth JWT. Conversas agente↔humano.
|
|
||||||
|
|
||||||
## POST /threads/{id}/reply
|
|
||||||
|
|
||||||
Body: `{"body": "...", "target_agent": "A6"}`
|
|
||||||
|
|
||||||
## POST /messages/{id}/ack
|
|
||||||
|
|
||||||
Arquiva item inbox.
|
|
||||||
|
|
||||||
## GET /scenarios · GET /findings · GET /action-log
|
|
||||||
|
|
||||||
Auth JWT. Painel operacional.
|
|
||||||
|
|
||||||
## POST /findings/{id}/ack
|
|
||||||
|
|
||||||
Marca finding visto.
|
|
||||||
|
|
||||||
## POST /runs/{scenario_id}
|
|
||||||
|
|
||||||
Roles: `super_admin`, `ops_lead`, `agentic_operator`.
|
|
||||||
|
|
||||||
## POST /chat
|
|
||||||
|
|
||||||
Body: `{"question": "...", "include_findings": true, "target_agent": "A6"}`
|
|
||||||
|
|
||||||
Response: `{"answer": "...", "model": "...", "kb_hits": N, "thread_id": 1}`
|
|
||||||
|
|
||||||
## POST /internal/tick
|
|
||||||
|
|
||||||
Header: `X-Ops-Internal-Token`. Worker cron 5 min.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"kb_indexed": 65,
|
|
||||||
"runs": [{"ok": true, "scenario_id": "desk.api.health", "status": "ok", "findings_count": 0}],
|
|
||||||
"total": 9
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
# Quickstart — Spec 029 Agentic Ops
|
|
||||||
|
|
||||||
## Staging (homologação — portas 8180/8192)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh root@10.10.10.122
|
|
||||||
cd /opt/ligbox-ops-platform-staging
|
|
||||||
git fetch && git checkout 029-agentic-ops-runbooks && git pull
|
|
||||||
cp .env.staging.example .env # ajustar tokens
|
|
||||||
docker compose -f docker-compose.agentic-staging.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validar T0
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://10.10.10.122:8180/api/health | jq .
|
|
||||||
curl -s http://10.10.10.122:8180/api/v1/agents/health | jq .
|
|
||||||
# Esperado: tier t1 se AGENTIC_LLM_ENABLED=true e Ollama OK
|
|
||||||
|
|
||||||
curl -s -X POST http://10.10.10.122:8180/api/v1/agents/internal/tick \
|
|
||||||
-H "X-Ops-Internal-Token: SEU_TOKEN" | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validar T1 (Ollama VM123)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh root@10.10.10.123 'curl -s http://127.0.0.1:11434/api/tags'
|
|
||||||
# Esperado: qwen2.5:7b-instruct, nomic-embed-text
|
|
||||||
```
|
|
||||||
|
|
||||||
### Validar chat (com JWT Desk)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
TOKEN=$(curl -s -X POST http://10.10.10.122:8180/api/v1/auth/login \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"username":"root","password":"..."}' | jq -r .access_token)
|
|
||||||
|
|
||||||
curl -s -X POST http://10.10.10.122:8180/api/v1/agents/chat \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"question":"Como validar FOSSBilling na VM123?"}' | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Checklist homologação (2026-06-19)
|
|
||||||
|
|
||||||
- [x] `/api/v1/agents/health` → 200, ollama true
|
|
||||||
- [x] Tick interno → 9 cenários, 103 ficheiros KB indexados
|
|
||||||
- [x] Findings gravados em SQLite staging (VM123 finance, OpenPanel bridge)
|
|
||||||
- [x] E-mail teste em finding critical (opcional)
|
|
||||||
- [x] UI Agentic Ops no Desk staging `:8192`
|
|
||||||
- [x] Chat copiloto endpoint `/chat` operacional (T1 quando Ollama responde <120s)
|
|
||||||
- [x] Produção `:8080` → `0.9.7-spec029-agentic` com agents API activa
|
|
||||||
|
|
||||||
## Promover produção
|
|
||||||
|
|
||||||
Somente após checklist:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/ligbox-ops-platform
|
|
||||||
git checkout 029-agentic-ops-runbooks && git pull
|
|
||||||
docker compose -f docker-compose.mvp.yml up -d --build api worker frontend
|
|
||||||
```
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# Cópia de referência — fonte de verdade em runtime:
|
|
||||||
# projects/ops-desk/api/app/agents/scenarios/registry.yaml
|
|
||||||
|
|
||||||
version: 1
|
|
||||||
scenarios:
|
|
||||||
- id: desk.api.health
|
|
||||||
title: Desk VM122 API
|
|
||||||
severity_default: high
|
|
||||||
agent_id: sentinel
|
|
||||||
- id: wizard.vm112.bundle
|
|
||||||
title: VM112 Wizard
|
|
||||||
severity_default: high
|
|
||||||
agent_id: A1
|
|
||||||
- id: pfsense.api.system
|
|
||||||
title: pfSense API
|
|
||||||
severity_default: warn
|
|
||||||
agent_id: A2
|
|
||||||
- id: funnel.stuck.onboarding
|
|
||||||
title: Funil travado
|
|
||||||
severity_default: warn
|
|
||||||
agent_id: A6
|
|
||||||
- id: integration.webhook.gap
|
|
||||||
title: Gap webhook VM112
|
|
||||||
severity_default: high
|
|
||||||
agent_id: sentinel
|
|
||||||
- id: proxmox.cluster
|
|
||||||
title: Proxmox VMs críticas
|
|
||||||
severity_default: critical
|
|
||||||
agent_id: A1
|
|
||||||
- id: ollama.vm123.health
|
|
||||||
title: Ollama VM123
|
|
||||||
severity_default: high
|
|
||||||
agent_id: sentinel
|
|
||||||
- id: vm123.finance.stack
|
|
||||||
title: VM123 Finance Stack
|
|
||||||
severity_default: high
|
|
||||||
agent_id: sentinel
|
|
||||||
- id: vm123.openpanel.bridge
|
|
||||||
title: OpenPanel Bridge VM123
|
|
||||||
severity_default: warn
|
|
||||||
agent_id: sentinel
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
# Spec 029 — Agentic Ops Runbooks (T0 → T1)
|
|
||||||
|
|
||||||
**Criado:** 2026-06-20
|
|
||||||
**Solicitado por:** Roger
|
|
||||||
**Status:** Homologação staging (branch `029-agentic-ops-runbooks`)
|
|
||||||
**Prioridade:** P1 (backlog AG-1)
|
|
||||||
**Sistemas:** VM122 (orquestração) · VM123 (Ollama LLM) · VM112/104/Proxmox/pfSense (alvos)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumo
|
|
||||||
|
|
||||||
Camada **Agentic Ops** para vigilância 24/7, checks determinísticos (T0), advisor LLM local (T1), e-mail em findings críticos, e copiloto contextual no Desk.
|
|
||||||
|
|
||||||
| Tier | Motor | Onde |
|
|
||||||
|------|-------|------|
|
|
||||||
| **T0** | Checks HTTP/SQLite + fallback texto | VM122 API + worker |
|
|
||||||
| **T1** | Ollama `qwen2.5:7b-instruct` + RAG specs | VM123 `:11434` |
|
|
||||||
|
|
||||||
**Produção Desk:** `8080` / `8091` — **não alterado** nesta entrega.
|
|
||||||
**Staging homologação:** `8180` / `8192` — stack isolada (`docker-compose.agentic-staging.yml`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agentes lógicos (implementação 029)
|
|
||||||
|
|
||||||
**Documento completo:** [`agents-roster.md`](agents-roster.md)
|
|
||||||
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cenários (registry.yaml)
|
|
||||||
|
|
||||||
1. `desk.api.health` — Desk VM122
|
|
||||||
2. `wizard.vm112.bundle` — VM112 API + portal
|
|
||||||
3. `pfsense.api.system` — pfSense via Traefik
|
|
||||||
4. `funnel.stuck.onboarding` — tickets >24h
|
|
||||||
5. `integration.webhook.gap` — gap VM112→122
|
|
||||||
6. `proxmox.cluster` — VMs 112/122/123/104
|
|
||||||
7. `ollama.vm123.health` — LLM backend
|
|
||||||
8. `vm123.finance.stack` — FOSS + Odoo
|
|
||||||
9. `vm123.openpanel.bridge` — bridge hosting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API (`/api/v1/agents/*`)
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| POST | `/findings/{id}/ack` | ops view |
|
|
||||||
| GET | `/action-log` | ops view |
|
|
||||||
| POST | `/runs/{scenario_id}` | super_admin, ops_lead, agentic_operator |
|
|
||||||
| POST | `/chat` | ops view (T1 copiloto) |
|
|
||||||
| POST | `/internal/tick` | token interno / cron worker |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worker
|
|
||||||
|
|
||||||
- `AGENTIC_INTERVAL_SEC=300` (5 min)
|
|
||||||
- `POST /api/v1/agents/internal/tick` via `OPS_INTERNAL_TOKEN`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notificações
|
|
||||||
|
|
||||||
- **E-mail:** findings `high`/`critical` → `DESK_ROOT_NOTIFY_EMAIL`
|
|
||||||
- **ntfy:** opcional via `DESK_OPS_NTFY_TOPIC`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Variáveis `.env`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
AGENTIC_LLM_ENABLED=true
|
|
||||||
OLLAMA_BASE_URL=http://10.10.10.123:11434
|
|
||||||
AGENTIC_LLM_MODEL=qwen2.5:7b-instruct
|
|
||||||
AGENTIC_EMBED_MODEL=nomic-embed-text
|
|
||||||
AGENTIC_INTERVAL_SEC=300
|
|
||||||
AGENTIC_SPECS_ROOT=/opt/ligbox-ops-platform/specs
|
|
||||||
AGENTIC_CRITICAL_VMIDS=112,122,123,104
|
|
||||||
VM123_IP=10.10.10.123
|
|
||||||
OPENPANEL_BRIDGE_URL=http://10.10.10.123:18087
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Homologação
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Staging VM122 (portas isoladas)
|
|
||||||
cd /opt/ligbox-ops-platform-staging
|
|
||||||
docker compose -f docker-compose.agentic-staging.yml up -d --build
|
|
||||||
curl -s http://10.10.10.122:8180/api/v1/agents/health
|
|
||||||
curl -s -X POST http://10.10.10.122:8180/api/v1/agents/internal/tick \
|
|
||||||
-H "X-Ops-Internal-Token: $OPS_INTERNAL_TOKEN"
|
|
||||||
```
|
|
||||||
|
|
||||||
Promover para produção apenas após checklist `quickstart.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentos relacionados
|
|
||||||
|
|
||||||
- Spec **027** — RBAC `agentic_operator`, A0–A7 governança
|
|
||||||
- Spec **019** — Console, políticas R0–R3
|
|
||||||
- `contracts/agent-platform-api.md`
|
|
||||||
- `quickstart.md`
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# Tasks — Spec 029 Agentic Ops
|
|
||||||
|
|
||||||
## Fase A — Fundação T0 ✅
|
|
||||||
- [x] A1 Tabelas SQLite (`agent_scenarios`, `agent_runs`, `agent_findings`, `agent_action_log`, `agent_kb_chunks`)
|
|
||||||
- [x] A2 Router `/api/v1/agents/*` montado em `main.py`
|
|
||||||
- [x] A3 `init_agent_schema` no boot
|
|
||||||
- [x] A4 Cenários registry.yaml (9 cenários)
|
|
||||||
- [x] A5 Checks T0 (`checks.py`)
|
|
||||||
|
|
||||||
## Fase B — Worker 24/7 ✅
|
|
||||||
- [x] B1 `agentic_tick()` no worker
|
|
||||||
- [x] B2 `AGENTIC_INTERVAL_SEC=300`
|
|
||||||
- [x] B3 `POST /internal/tick`
|
|
||||||
|
|
||||||
## Fase C — T1 LLM ✅
|
|
||||||
- [x] C1 Ollama VM123 `qwen2.5:7b-instruct`
|
|
||||||
- [x] C2 `advise_human_action` em findings warn+
|
|
||||||
- [x] C3 `chat_context` copiloto
|
|
||||||
- [x] C4 KB Curator index specs
|
|
||||||
|
|
||||||
## Fase D — Agentes nomeados A0–A7 ✅
|
|
||||||
- [x] D1 `catalog.py` roster
|
|
||||||
- [x] D2 `agents-roster.md`
|
|
||||||
- [x] D3 Map cenário → agente (A1, A2, A6, sentinel)
|
|
||||||
|
|
||||||
## Fase E — Mensagens operadores ✅
|
|
||||||
- [x] E1 `agent_threads` + `agent_messages`
|
|
||||||
- [x] E2 Inbox por role
|
|
||||||
- [x] E3 Reply humano + ack
|
|
||||||
- [x] E4 UI Desk 3 colunas (roster/inbox/contexto)
|
|
||||||
- [x] E5 E-mail high/critical via Postfix
|
|
||||||
|
|
||||||
## Fase F — Staging homologação ✅
|
|
||||||
- [x] F1 Deploy `docker-compose.agentic-staging.yml` (:8180/:8192)
|
|
||||||
- [x] F2 Checklist quickstart.md
|
|
||||||
- [x] F3 Chat T1 com Ollama online (health tier t1; resposta LLM ~2min qwen2.5:7b)
|
|
||||||
- [x] F4 Inbox com finding simulado (VM123 finance + OpenPanel bridge)
|
|
||||||
|
|
||||||
## Fase G — Produção ✅
|
|
||||||
- [x] G1 Merge branch → main (pendente push remoto se aplicável)
|
|
||||||
- [x] G2 Deploy produção `:8080` (api + worker + frontend) — versão `0.9.7-spec029-agentic`
|
|
||||||
- [x] G3 Verificar produção intacta — `/api/v1/agents/health` 200 tier t1
|
|
||||||
- [x] G4 Registar homologação — 2026-06-19 VM122
|
|
||||||
|
|
||||||
## Fase H — Futuro (fora MVP)
|
|
||||||
- [ ] H1 A3–A5 cenários deliverability/SOC/mail
|
|
||||||
- [ ] H2 A7 runbooks R0–R3 + fila aprovação
|
|
||||||
- [ ] H3 WebSocket live push
|
|
||||||
- [ ] H4 Embeddings `nomic-embed-text` semântico
|
|
||||||
Loading…
Reference in a new issue