Compare commits

..

7 commits

Author SHA1 Message Date
Ligbox Spec Hub
92148e5980 Mark Spec 029 phases F/G homologation complete.
Document staging checklist results and production deploy 0.9.7-spec029-agentic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:47:55 +00:00
Ligbox Spec Hub
d066586023 Serialize agentic ticks and retry SQLite writes under concurrent load.
Redis lock prevents overlapping worker ticks; auth login retries on DB locked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:36:41 +00:00
Ligbox Spec Hub
33db764c74 Fix agentic tick SQLite locking and health version reporting.
Commit KB chunks per file with retry on locked DB; expose app.version in /api/health.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:30:42 +00:00
Ligbox Spec Hub
bf367a75eb Sync app/permissions.py with Spec 027 RBAC for vm123 and agentic routes.
Fixes ImportError on staging API build (can_access_foss_admin missing).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:26:24 +00:00
Ligbox Spec Hub
62463aa76f Complete Spec 029 docs and staging frontend proxy for agentic API.
Adds tasks.md, scenarios copy, full API contract, Dockerfile.staging
with nginx proxy to api-staging container on port 8192.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:25:28 +00:00
Ligbox Spec Hub
2a5273201b Name Agentics A0-A7, add inter-agent messaging and operator inbox UI.
Adds catalog with Maestro/Pulso/Trilho etc., agent_threads/messages bus,
inbox and context window API, and complete Desk Agentic Ops panel for
human operators to read, reply, and chat with agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:24:48 +00:00
Ligbox Spec Hub
e0959e6fd7 Add Agentic Ops Spec 029: wire API, worker tick, T0/T1, staging stack.
Mounts agents router and schema init, adds VM123 checks, chat copilot,
Desk UI module, isolated docker-compose staging on ports 8180/8192,
and full spec documentation without touching production ports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:22:33 +00:00
30 changed files with 2108 additions and 109 deletions

View file

@ -123,7 +123,7 @@
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
| **AG-1** | P3 | Agentes IA + runbooks | 📋 |
| **AG-1** | P1 | Agentes IA + runbooks (Spec 029) | 🔄 staging |
---

View file

@ -0,0 +1,47 @@
#!/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

@ -0,0 +1,208 @@
"""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,6 +1,10 @@
"""T0/T1 checks — Spec 029."""
from __future__ import annotations
import os, sqlite3, time
import os
import sqlite3
import time
import httpx
DESK = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br")
@ -11,85 +15,252 @@ PFS_USER = os.getenv("PFSENSE_API_USER", "api_cursor")
PFS_PASS = os.getenv("PFSENSE_API_PASSWORD", "805353")
PVE = os.getenv("PVE_API_URL", "https://10.10.10.2:8006/api2/json")
PVE_USER = os.getenv("PVE_USER", "root@pam")
PVE_PASS = os.getenv("PVE_PASSWORD", "@betinplace")
PVE_PASS = os.getenv("PVE_PASSWORD", "")
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()]
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):
t0 = time.perf_counter()
try:
with httpx.Client(timeout=15, verify=False, follow_redirects=True) as c:
r = c.get(url, auth=auth)
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}
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}
except Exception as e:
return {"ok": False, "error": str(e), "url": url}
def check_desk_api_health():
r = _http(f"{DESK}/api/health")
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"}]
r = _http(f"{DESK}/api/health", max_ms=4000)
return [] if r["ok"] else [
{
"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():
out = []
r1 = _http(f"{VM112}/api/onboarding/health")
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"})
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",
}
)
r2 = _http(WIZARD, max_ms=4000)
if not r2["ok"]: out.append({"severity":"warn","category":"api","title":"Portal /onboard falhou","detail_md":str(r2),"evidence":r2,"human_action":"Traefik + VM112"})
if not r2["ok"]:
out.append(
{
"severity": "warn",
"category": "api",
"title": "Portal /onboard falhou",
"detail_md": str(r2),
"evidence": r2,
"human_action": "Traefik CT114 + VM112",
}
)
return out
def check_pfsense_api():
r = _http(PFS_URL, auth=(PFS_USER, PFS_PASS), max_ms=4000)
return [] if r["ok"] else [{"severity":"warn","category":"infra","title":"pfSense API falhou","detail_md":str(r),"evidence":r,"human_action":"firewall.itecnologys.com"}]
return [] if r["ok"] else [
{
"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):
try:
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"]
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":"ASM Spec 010"}]
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"]
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:
return []
def check_integration_gap(ops_api_url, token):
if not token: return []
if not token:
return []
try:
with httpx.Client(timeout=15) as c:
r = c.get(f"{ops_api_url}/api/v1/integrations/health", headers={"X-Ops-Internal-Token": token})
if r.status_code != 200: return []
if r.status_code != 200:
return []
gap = (r.json().get("vm112_onboard") or {}).get("gap_minutes")
if gap is None or int(gap) <= 15: 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"}]
if gap is None or int(gap) <= 15:
return []
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:
return []
def check_proxmox_cluster():
if not PVE_PASS:
return []
try:
with httpx.Client(timeout=15, verify=False) as c:
t = c.post(f"{PVE}/access/ticket", data={"username": PVE_USER, "password": PVE_PASS})
if t.status_code != 200:
return [{"severity":"warn","category":"infra","title":"Proxmox auth falhou","detail_md":str(t.status_code),"evidence":{},"human_action":"PVE 10.10.10.2:8006"}]
return [
{
"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"]
bad = []
with httpx.Client(timeout=15, verify=False) as c:
for vmid in VMIDS:
r = c.get(f"{PVE}/nodes/{PVE_NODE}/qemu/{vmid}/status/current", headers={"Cookie": f"PVEAuthCookie={tok}"})
r = c.get(
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"
if st != "running": bad.append({"vmid": vmid, "status": st})
if not bad: return []
return [{"severity":"critical","category":"infra","title":f"VMs paradas {bad}","detail_md":str(bad),"evidence":{"bad":bad},"human_action":"qm start no big1"}]
if st != "running":
bad.append({"vmid": vmid, "status": st})
if not bad:
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:
return [{"severity":"info","category":"infra","title":"Proxmox check erro","detail_md":str(e),"evidence":{},"human_action":""}]
return [
{
"severity": "info",
"category": "infra",
"title": "Proxmox check erro",
"detail_md": str(e),
"evidence": {},
"human_action": "",
}
]
def check_ollama_vm123():
r = _http(f"{OLLAMA}/api/tags", max_ms=5000)
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"}]
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",
}
]
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 = {
"desk.api.health": lambda conn, **kw: check_desk_api_health(),
"wizard.vm112.bundle": lambda conn, **kw: check_vm112_health(),
"pfsense.api.system": lambda conn, **kw: check_pfsense_api(),
"funnel.stuck.onboarding": lambda conn, **kw: check_funnel_stuck(conn),
"integration.webhook.gap": lambda conn, **kw: check_integration_gap(kw.get("ops_api_url",""), kw.get("internal_token","")),
"integration.webhook.gap": lambda conn, **kw: check_integration_gap(
kw.get("ops_api_url", ""), kw.get("internal_token", "")
),
"proxmox.cluster": lambda conn, **kw: check_proxmox_cluster(),
"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,12 +1,16 @@
"""Ollama VM123 + fallback — Spec 029 T1."""
"""Ollama VM123 + fallback — Spec 029 T0/T1."""
from __future__ import annotations
import os
import httpx
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_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
def ollama_available() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
@ -14,25 +18,70 @@ def ollama_available() -> bool:
except Exception:
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]:
if not AGENTIC_LLM_ENABLED or not ollama_available():
return ("", "t0")
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 r.status_code == 200:
txt = (r.json().get("message") or {}).get("content", "").strip()
if txt:
return txt, AGENTIC_LLM_MODEL
except Exception:
pass
return ("", "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}\nKB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
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",
)
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:
txt = (r.json().get("message") or {}).get("content", "").strip()
if txt:
return txt, AGENTIC_LLM_MODEL
except Exception:
pass
return (f"Rever logs e specs para: {finding_title}", "t0-fallback")

View file

@ -0,0 +1,289 @@
"""Bus de mensagens agente↔agente↔humano — Spec 029."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
from app.agents.catalog import AGENT_CATALOG, resolve_agent
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_messages_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
CREATE TABLE IF NOT EXISTS agent_threads (
id INTEGER PRIMARY KEY,
subject TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'info',
status TEXT NOT NULL DEFAULT 'open',
primary_agent TEXT NOT NULL,
related_finding_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL,
from_type TEXT NOT NULL,
from_id TEXT NOT NULL,
to_type TEXT NOT NULL,
to_id TEXT NOT NULL,
body TEXT NOT NULL,
context_json TEXT,
requires_human INTEGER NOT NULL DEFAULT 0,
human_role_hint TEXT,
acknowledged_at TEXT,
acknowledged_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (thread_id) REFERENCES agent_threads(id)
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_thread ON agent_messages(thread_id);
CREATE INDEX IF NOT EXISTS idx_agent_messages_inbox ON agent_messages(requires_human, acknowledged_at);
""")
def create_thread(
conn: sqlite3.Connection,
*,
subject: str,
primary_agent: str,
severity: str = "info",
related_finding_id: int | None = None,
) -> int:
now = _now()
return int(
conn.execute(
"""INSERT INTO agent_threads (subject, severity, status, primary_agent, related_finding_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?)""",
(subject, severity, "open", primary_agent, related_finding_id, now, now),
).lastrowid
)
def post_message(
conn: sqlite3.Connection,
*,
thread_id: int,
from_type: str,
from_id: str,
to_type: str,
to_id: str,
body: str,
context: dict | None = None,
requires_human: bool = False,
human_role_hint: str | None = None,
) -> int:
now = _now()
mid = int(
conn.execute(
"""INSERT INTO agent_messages
(thread_id, from_type, from_id, to_type, to_id, body, context_json, requires_human, human_role_hint, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
thread_id,
from_type,
from_id,
to_type,
to_id,
body,
json.dumps(context or {}),
1 if requires_human else 0,
human_role_hint,
now,
),
).lastrowid
)
conn.execute("UPDATE agent_threads SET updated_at=? WHERE id=?", (now, thread_id))
return mid
def notify_finding_to_operators(
conn: sqlite3.Connection,
*,
finding_id: int,
scenario_id: str,
title: str,
severity: str,
human_action: str,
agent_id: str,
) -> int:
"""Abre thread + mensagem para operadores humanos."""
profile = resolve_agent(scenario_id, agent_id)
role_hint = {
"critical": "agentic_operator",
"high": "ops_lead",
"warn": "technician",
}.get(severity, "technician")
existing = conn.execute(
"SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'",
(finding_id,),
).fetchone()
if existing:
thread_id = existing["id"]
else:
thread_id = create_thread(
conn,
subject=title,
primary_agent=profile.id,
severity=severity,
related_finding_id=finding_id,
)
agent_name = profile.name
body = (
f"**{agent_name}** ({profile.id}) detectou: {title}\n\n"
f"Acção sugerida: {human_action or 'Investigar manualmente.'}\n\n"
f"Cenário: `{scenario_id}` · Severidade: **{severity}**"
)
# Mensagem agente → humanos (inbox operadores)
post_message(
conn,
thread_id=thread_id,
from_type="agent",
from_id=profile.id,
to_type="human",
to_id=role_hint,
body=body,
context={"finding_id": finding_id, "scenario_id": scenario_id, "severity": severity},
requires_human=severity in ("high", "critical", "warn"),
human_role_hint=role_hint,
)
# Maestro (A0) regista coordenação inter-agente
if profile.id not in ("A0", "orchestrator"):
post_message(
conn,
thread_id=thread_id,
from_type="agent",
from_id="A0",
to_type="agent",
to_id=profile.id,
body=f"Registado finding #{finding_id}. Aguardando acção humana ({role_hint}).",
context={"coordination": True},
requires_human=False,
)
return thread_id
def list_inbox(conn: sqlite3.Connection, *, role: str, limit: int = 50) -> list[dict]:
"""Mensagens pendentes para operadores humanos."""
role_priority = {
"super_admin": ("agentic_operator", "ops_lead", "technician", "noc", "devops", "security_analyst"),
"agentic_operator": ("agentic_operator", "ops_lead"),
"ops_lead": ("ops_lead", "agentic_operator", "technician"),
"technician": ("technician",),
"security_analyst": ("security_analyst", "agentic_operator"),
"devops": ("devops", "ops_lead"),
"noc": ("noc",),
"developer": ("developer", "ops_lead"),
}
allowed = role_priority.get(role, (role,))
q = """
SELECT m.*, t.subject AS thread_subject, t.severity AS thread_severity, t.primary_agent
FROM agent_messages m
JOIN agent_threads t ON t.id = m.thread_id
WHERE m.requires_human = 1 AND m.acknowledged_at IS NULL
AND m.to_type = 'human'
ORDER BY m.id DESC LIMIT ?
"""
rows = [dict(r) for r in conn.execute(q, (limit * 3,))]
out = []
for r in rows:
hint = r.get("human_role_hint") or r.get("to_id") or ""
if role == "super_admin" or hint in allowed or role in allowed:
r["agent_name"] = AGENT_CATALOG.get(r["from_id"], AGENT_CATALOG.get("sentinel")).name
out.append(r)
if len(out) >= limit:
break
return out
def list_threads(conn: sqlite3.Connection, *, limit: int = 40) -> list[dict]:
rows = conn.execute(
"SELECT * FROM agent_threads ORDER BY updated_at DESC LIMIT ?", (limit,)
).fetchall()
out = []
for r in rows:
item = dict(r)
p = AGENT_CATALOG.get(item["primary_agent"])
item["agent_name"] = p.name if p else item["primary_agent"]
pending = conn.execute(
"SELECT COUNT(*) c FROM agent_messages WHERE thread_id=? AND requires_human=1 AND acknowledged_at IS NULL",
(item["id"],),
).fetchone()["c"]
item["pending_human"] = pending
out.append(item)
return out
def thread_messages(conn: sqlite3.Connection, thread_id: int) -> list[dict]:
rows = conn.execute(
"SELECT * FROM agent_messages WHERE thread_id=? ORDER BY id ASC", (thread_id,)
).fetchall()
out = []
for r in rows:
item = dict(r)
if item["from_type"] == "agent":
p = AGENT_CATALOG.get(item["from_id"])
item["from_label"] = f"{p.name} ({item['from_id']})" if p else item["from_id"]
else:
item["from_label"] = item["from_id"]
out.append(item)
return out
def human_reply(
conn: sqlite3.Connection,
*,
thread_id: int,
username: str,
body: str,
target_agent: str | None = None,
) -> int:
agent_to = target_agent or conn.execute(
"SELECT primary_agent FROM agent_threads WHERE id=?", (thread_id,)
).fetchone()["primary_agent"]
mid = post_message(
conn,
thread_id=thread_id,
from_type="human",
from_id=username,
to_type="agent",
to_id=agent_to,
body=body,
requires_human=False,
)
# Copiloto (A6) ecoa confirmação para o thread
post_message(
conn,
thread_id=thread_id,
from_type="agent",
from_id="A6",
to_type="human",
to_id=username,
body=f"Recebi a sua instrução. Vou coordenar com **{AGENT_CATALOG.get(agent_to, AGENT_CATALOG['A6']).name}** e actualizar o finding se aplicável.",
requires_human=False,
)
return mid
def ack_message(conn: sqlite3.Connection, message_id: int, username: str) -> bool:
row = conn.execute("SELECT id FROM agent_messages WHERE id=?", (message_id,)).fetchone()
if not row:
return False
conn.execute(
"UPDATE agent_messages SET acknowledged_at=?, acknowledged_by=? WHERE id=?",
(_now(), username, message_id),
)
return True

View file

@ -16,5 +16,7 @@ def load_registry() -> list[dict]:
{"id": "funnel.stuck.onboarding", "title": "Funil travado", "severity_default": "warn"},
{"id": "integration.webhook.gap", "title": "Gap webhook VM112", "severity_default": "high"},
{"id": "proxmox.cluster", "title": "Proxmox VMs críticas", "severity_default": "critical"},
{"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high"},
{"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,63 +1,237 @@
"""Agentic API — Spec 029."""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth
from app.agents import llm_client, runner, store
from app.agents import messages as agent_messages
from app.agents.catalog import roster_public
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
def _db():
conn = auth.db()
try: yield conn
finally: conn.close()
try:
yield conn
finally:
conn.close()
def _ops_view(user):
if user.role not in ("super_admin","ops_lead","technician","noc","agentic_operator"):
if user.role not in (
"super_admin",
"ops_lead",
"technician",
"noc",
"agentic_operator",
"developer",
"devops",
"security_analyst",
):
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")
def agents_health():
return {"status":"ok","tier":"t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
"ollama": llm_client.ollama_available(), "ollama_url": llm_client.OLLAMA_BASE_URL,
"model": llm_client.AGENTIC_LLM_MODEL}
return {
"status": "ok",
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
"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")
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user); runner.sync_registry(conn); conn.commit()
_ops_view(user)
runner.sync_registry(conn)
conn.commit()
return {"scenarios": store.list_scenarios(conn)}
@router.get("/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):
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,
):
_ops_view(user)
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
@router.post("/findings/{finding_id}/ack")
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
raise HTTPException(404, "not found")
now = datetime.now(timezone.utc).isoformat()
conn.execute("UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", (now, user.username, finding_id))
conn.execute(
"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})
conn.commit()
return {"ok": True, "id": finding_id}
@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)
return {"events": store.list_action_log(conn, limit=limit)}
@router.post("/runs/{scenario_id}")
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
if user.role not in ("super_admin","ops_lead"): raise HTTPException(403, "insufficient permissions")
if user.role not in ("super_admin", "ops_lead", "agentic_operator"):
raise HTTPException(403, "insufficient permissions")
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
conn.commit(); return r
conn.commit()
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")
def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)):
kb = runner.index_specs_kb(conn)
result = runner.run_all_enabled(conn, trigger="cron")
store.log_event(conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", payload={"kb": kb, **result})
store.log_event(
conn,
event_type="tick.complete",
message=f"kb={kb} runs={result['total']}",
agent_id="A0",
payload={"kb": kb, **result},
)
conn.commit()
return {"ok": True, "kb_indexed": kb, **result}

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import os
from pathlib import Path
from app.agents import checks, llm_client, notify, registry, store
from app.agents import messages as agent_messages
from app.agents.catalog import SCENARIO_AGENT_MAP
SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs"))
OPS_API = os.getenv("OPS_API_URL", "http://api:8080")
@ -28,11 +30,14 @@ def index_specs_kb(conn):
def run_scenario(conn, scenario_id, *, trigger="cron"):
sc = store.get_scenario(conn, scenario_id)
if not sc: return {"ok": False, "error": "not found"}
if not sc:
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)
if not fn: return {"ok": False, "error": "no runner"}
if not fn:
return {"ok": False, "error": "no runner"}
run_id = store.create_run(conn, scenario_id, trigger)
store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id)
store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id, agent_id=agent_id)
raw = fn(conn, ops_api_url=OPS_API, internal_token=TOKEN)
fids = []
for f in raw:
@ -48,12 +53,23 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
category=f.get("category","api"), title=f.get("title","Finding"), detail_md=f.get("detail_md",""),
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
fids.append(fid)
if f.get("severity") in ("warn", "high", "critical"):
agent_messages.notify_finding_to_operators(
conn,
finding_id=fid,
scenario_id=scenario_id,
title=f.get("title", "Finding"),
severity=f.get("severity", "warn"),
human_action=human,
agent_id=agent_id,
)
if f.get("severity") in ("high", "critical"):
notify.notify_finding({**f, "suggested_human_action": human})
store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid})
store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id)
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.log_event(conn, event_type="run.finish", message=status, run_id=run_id)
store.log_event(conn, event_type="run.finish", message=status, run_id=run_id, agent_id=agent_id)
conn.commit()
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"):

View file

@ -3,21 +3,36 @@ 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,13 +1,25 @@
"""Persistence Agentic Ops."""
from __future__ import annotations
import json, sqlite3
import json, sqlite3, time
from datetime import datetime, timezone
from typing import Any
from app.agents.messages import init_messages_schema
def _now():
return datetime.now(timezone.utc).isoformat()
def _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):
init_messages_schema(conn)
conn.executescript("""
CREATE TABLE IF NOT EXISTS agent_scenarios (
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,
@ -87,11 +99,12 @@ 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,))]
def index_kb_file(conn, source_path, text):
conn.execute("DELETE FROM agent_kb_chunks WHERE source_path=?", (source_path,))
_exec_retry(conn, "DELETE FROM agent_kb_chunks WHERE source_path=?", (source_path,))
now = _now()
for i in range(0, len(text), 1200):
conn.execute("INSERT INTO agent_kb_chunks (source_path,chunk_text,indexed_at) VALUES (?,?,?)",
_exec_retry(conn, "INSERT INTO agent_kb_chunks (source_path,chunk_text,indexed_at) VALUES (?,?,?)",
(source_path, text[i:i+1200], now))
conn.commit()
def search_kb(conn, query, limit=8):
terms = [t.strip().lower() for t in query.split() if len(t.strip()) > 2]

View file

@ -14,6 +14,7 @@ from typing import Any
from fastapi import Depends, Header, HTTPException, Request
from jose import JWTError, jwt
import bcrypt
import time
from app.totp_util import verify_code as verify_totp_code
@ -55,7 +56,7 @@ def db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA busy_timeout=60000")
return conn
@ -188,12 +189,19 @@ def check_credentials(username: str, password: str) -> tuple[DeskUser | None, sq
def touch_last_login(username: str) -> None:
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
conn.execute(
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
(now, now, username),
)
conn.commit()
for attempt in range(8):
try:
with db() as conn:
conn.execute(
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
(now, now, username),
)
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:

View file

@ -28,6 +28,8 @@ from app.billing_routes import router as billing_router
from app.security_routes import router as security_router
from app.infra_stack_routes import router as infra_stack_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.permissions import (
can_assign_ticket,
@ -117,7 +119,7 @@ ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.as
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.0-desk-assist")
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(auth_router)
app.include_router(registration_router)
@ -133,6 +135,7 @@ app.include_router(migration_router)
app.include_router(billing_router)
app.include_router(infra_stack_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"
@ -142,7 +145,7 @@ def db():
conn = sqlite3.connect(DB_PATH, timeout=30.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA busy_timeout=60000")
return conn
@ -185,8 +188,9 @@ def init_db():
init_purge_jobs_schema(conn)
init_purge_auth_schema(conn)
init_agent_schema(conn)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA busy_timeout=60000")
conn.commit()
@ -823,7 +827,7 @@ def startup():
@app.get("/api/health")
def health():
redis.from_url(REDIS_URL).ping()
return {"status": "ok", "service": "ligbox-ops-api", "version": "0.9.6-spec019-023"}
return {"status": "ok", "service": "ligbox-ops-api", "version": app.version}
@app.get("/api/v1/integrations")

View file

@ -47,6 +47,13 @@ MODULES: tuple[ModuleDef, ...] = (
description="Painel visual SOC VM112→VM122.",
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(
id="funnel-timing",
label="Relógio por fase",
@ -135,6 +142,29 @@ MODULES: tuple[ModuleDef, ...] = (
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]:
return [m.id for m in MODULES]

View file

@ -1,19 +1,71 @@
"""RBAC helpers for Ligbox Ops Desk."""
"""RBAC helpers for Ligbox Ops Desk — Spec 003 + 027."""
from __future__ import annotations
ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
# Ops (Spec 003)
OPS_ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
ROLE_LABELS = {
# Comercial (Spec 027)
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",
"ops_lead": "Chefe Ops",
"technician": "Suporte",
"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:
return role in ROLES
return role in HUMAN_ROLES
def can_patch_ticket(role: str, ticket: dict, username: str) -> bool:
@ -38,37 +90,99 @@ def can_run_audit(role: str) -> bool:
def can_read_audit_overview(role: str) -> bool:
return role in ("super_admin", "ops_lead", "noc")
return role in (
"super_admin",
"ops_lead",
"noc",
"developer",
"devops",
"security_analyst",
"agentic_operator",
)
def can_read_audit_scorecard(role: str) -> bool:
return role in ("super_admin", "ops_lead", "noc")
return role in (
"super_admin",
"ops_lead",
"noc",
"developer",
"security_analyst",
"agentic_operator",
)
def can_read_cloudflare_dns(role: str) -> bool:
return role in ("super_admin", "ops_lead", "technician", "noc")
return role in (
"super_admin",
"ops_lead",
"technician",
"noc",
"seo",
"devops",
"developer",
)
def can_read_funnel(role: str) -> bool:
return role in ROLES
return role in (
"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:
return role in ("super_admin", "ops_lead", "technician")
return role in (
"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:
if role == "noc":
return source in (None, "wazuh", "vm112-security")
return role in ROLES
if role == "security_analyst":
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:
return role in ("super_admin", "ops_lead", "technician")
return role in (
"super_admin",
"ops_lead",
"technician",
"sales_admin",
"sales_support",
"marketing",
"seo",
)
def can_read_assist(role: str) -> bool:
return role in ROLES
return role in ("super_admin", "ops_lead", "technician", "sales_admin", "sales_support")
def can_assist_takeover(role: str) -> bool:
@ -85,15 +199,15 @@ def can_manage_users(role: str) -> bool:
def can_manage_vm112_domains(role: str) -> bool:
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
return role in ("super_admin", "ops_lead")
return role in ("super_admin", "ops_lead", "devops")
def should_mask_sensitive(role: str) -> bool:
return role == "noc"
return role in ("noc", "sales_support")
def can_read_migration(role: str) -> bool:
return role in ("super_admin", "ops_lead", "technician", "noc")
return role in ("super_admin", "ops_lead", "technician", "noc", "devops")
def can_manage_migration(role: str) -> bool:
@ -101,8 +215,67 @@ def can_manage_migration(role: str) -> bool:
def can_read_billing(role: str) -> bool:
return role in ROLES
return role in (
"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:
return role in ("super_admin", "ops_lead")
return can_validate_billing(role)
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,3 +6,4 @@ python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
pyotp==2.9.0
pyyaml==6.0.2

View file

@ -0,0 +1,26 @@
"""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

@ -0,0 +1,48 @@
# 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

@ -0,0 +1,4 @@
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,32 +1,194 @@
(function () {
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 = {}) {
const h = { ...(opts.headers || {}) };
if (!(opts.body instanceof FormData)) h['Content-Type'] = 'application/json';
const t = window.DeskAuth?.getToken?.();
if (t) h.Authorization = `Bearer ${t}`;
const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h });
if (!r.ok) throw new Error(`${r.status}`);
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
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() {
const el = document.getElementById('agentic-ops-content');
if (!el) return;
el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>';
try {
const [health, scenarios, findings, log] = await Promise.all([
api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'),
const [health, roster, inbox, threads, findings] = await Promise.all([
api('/health'),
api('/roster'),
api('/inbox?limit=20'),
api('/threads?limit=15'),
api('/findings?limit=15'),
]);
const tier = health.tier === 't1' ? 'T1 LLM' : 'T0';
const ollama = health.ollama ? '<span class="pill pill-ok">Ollama OK</span>' : '<span class="pill pill-warn">Ollama offline</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('');
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 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('');
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 ollama = health.ollama
? `<span class="pill pill-ok">Ollama · ${esc(health.model)}</span>`
: '<span class="pill pill-warn">Ollama offline</span>';
const agents = roster.agents || [];
const inboxItems = inbox.messages || [];
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.querySelectorAll('[data-ack]').forEach(btn => btn.addEventListener('click', async () => {
await api(`/findings/${btn.dataset.ack}/ack`, { method: 'POST' });
await renderAgenticOps();
}));
el.querySelectorAll('.agentic-agent-card').forEach(card => {
card.addEventListener('click', () => {
state.selectedAgent = card.dataset.agentId;
renderAgenticOps();
});
});
el.querySelectorAll('[data-open-thread]').forEach(btn => {
btn.addEventListener('click', () => loadThread(el, parseInt(btn.dataset.openThread, 10)));
});
el.querySelectorAll('[data-ack-msg]').forEach(btn => {
btn.addEventListener('click', async () => {
await api(`/messages/${btn.dataset.ackMsg}/ack`, { method: 'POST' });
await renderAgenticOps();
});
});
el.querySelector('#agentic-thread-select')?.addEventListener('change', (e) => {
const id = parseInt(e.target.value, 10);
if (id) loadThread(el, id);
});
el.querySelector('#btn-agentic-reply')?.addEventListener('click', async () => {
const input = el.querySelector('#agentic-reply-input');
const tid = state.threadId || parseInt(el.querySelector('#agentic-thread-select')?.value, 10);
const body = (input?.value || '').trim();
if (!tid || !body) return;
await api(`/threads/${tid}/reply`, {
method: 'POST',
body: JSON.stringify({ body, target_agent: state.selectedAgent }),
});
input.value = '';
await loadThread(el, tid);
});
el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => {
const input = el.querySelector('#agentic-chat-input');
const out = el.querySelector('#agentic-chat-answer');
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) {
el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
}

View file

@ -76,6 +76,7 @@ const views = {
'email-migration': document.getElementById('view-email-migration'),
infra: document.getElementById('view-infra'),
infra2: document.getElementById('view-infra2'),
'agentic-ops': document.getElementById('view-agentic-ops'),
messages: document.getElementById('view-messages'),
admin: document.getElementById('view-admin'),
account: document.getElementById('view-account'),
@ -210,6 +211,7 @@ function setView(name) {
tenants: 'Tenants',
infra: 'INFRA CODE',
infra2: 'SOC — Infra 2',
'agentic-ops': 'Agentic Ops',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
account: 'Minha conta',
@ -225,6 +227,7 @@ function setView(name) {
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
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',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
@ -4221,6 +4224,7 @@ async function refresh(options = {}) {
if (state.view === 'tenants') await renderTenants();
if (state.view === 'infra') await renderInfra();
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 === 'admin') await renderAdmin();
if (state.view === 'modules') await renderModules();

View file

@ -225,6 +225,10 @@
<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>
</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">
<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>
@ -326,6 +330,10 @@
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
</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">
<div id="account-content"><p class="loading">Carregando…</p></div>
</section>
@ -434,7 +442,8 @@
<script src="/assets/desk-live-stub.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/servicos.js?v=20260619tickets2"></script>
<script src="/assets/app.js?v=20260619tickets2"></script>
<script src="/assets/servicos.js?v=20260620agentic"></script>
<script src="/assets/agentic-ops.js?v=20260620agentic"></script>
<script src="/assets/app.js?v=20260620agentic"></script>
</body>
</html>

View file

@ -0,0 +1,20 @@
# 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,6 +14,7 @@ WORKER_INTERVAL = int(os.getenv("WORKER_INTERVAL", "120"))
AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600"))
LEAD_SYNC_INTERVAL_SEC = int(os.getenv("LEAD_SYNC_INTERVAL_SEC", "900"))
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()
@ -40,6 +41,29 @@ def poll_vm112() -> None:
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:
if not OPS_INTERNAL_TOKEN:
return
@ -83,6 +107,7 @@ def main() -> None:
print("[worker] started", flush=True)
last_audit = 0.0
last_lead_sync = 0.0
last_agentic = 0.0
while True:
event = redis_client.rpop("ops:events")
if event:
@ -96,6 +121,9 @@ def main() -> None:
sync_stale_leads()
check_integration_gap()
last_lead_sync = now
if now - last_agentic >= AGENTIC_INTERVAL_SEC:
agentic_tick(redis_client)
last_agentic = now
time.sleep(WORKER_INTERVAL)

View file

@ -0,0 +1,142 @@
# 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

@ -0,0 +1,73 @@
# 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

@ -0,0 +1,62 @@
# 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

@ -0,0 +1,41 @@
# 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

@ -0,0 +1,131 @@
# 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

@ -0,0 +1,49 @@
# 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