Compare commits

..

No commits in common. "92148e5980e1e0159a279645468fde6db42e236d" and "acaacce70522344bbe10e1ae750199dda7668a8e" have entirely different histories.

30 changed files with 109 additions and 2108 deletions

View file

@ -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 | 📋 |
--- ---

View file

@ -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"

View file

@ -1,208 +0,0 @@
"""Catálogo nomeado dos Agentics A0A7 — 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

View file

@ -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(),
} }

View file

@ -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",
)

View file

@ -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

View file

@ -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"},
] ]

View file

@ -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}

View file

@ -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"):

View file

@ -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

View file

@ -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]

View file

@ -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:

View file

@ -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")

View file

@ -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]

View file

@ -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),
}

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -1,194 +1,32 @@
(function () { (function () {
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 (A0A7)</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>`;
} }

View file

@ -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();

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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)

View file

@ -1,142 +0,0 @@
# Roster Agentics — Nomes, acções e janelas de contexto
**Spec 029 · Secção agentes**
---
## Mapa A0A7 (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 R0R3
- 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 A0A7 (clique selecciona agente destino)
2. **Coluna centro:** Inbox operadores + findings abertos
3. **Coluna direita:** Janela de contexto (thread + reply + chat Copiloto)

View file

@ -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 A0A7 + 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
}
```

View file

@ -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
```

View file

@ -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

View file

@ -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 A0A7 |
| 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`, A0A7 governança
- Spec **019** — Console, políticas R0R3
- `contracts/agent-platform-api.md`
- `quickstart.md`

View file

@ -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 A0A7 ✅
- [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 A3A5 cenários deliverability/SOC/mail
- [ ] H2 A7 runbooks R0R3 + fila aprovação
- [ ] H3 WebSocket live push
- [ ] H4 Embeddings `nomic-embed-text` semântico