feat: zero-touch Agent — central scheduler, central pause, delta sync, discovery-driven installer

Hub: installations.processing_enabled / sync_interval_seconds / last_sync_* (migration 0026).
The first heartbeat of an installation without coverage queues exactly one INITIAL_SYNC;
afterwards one SYNC_INCREMENTAL per interval (idempotent, backs off on failure). One fiscal
job at a time per Agent (FIFO, stale RUNNING recovery). Job completion now updates the
coverage snapshot, blockers and JOB_STARTED/COMPLETED/FAILED events. Panel: Sincronizar
agora / Reprocessar tudo / Pausar / Retomar + status; pause is enforced Hub-side.
Agent: local AGENT_JOBS_ENABLED is ignored (central control); SYNC_INCREMENTAL re-classifies
only new/changed products via a per-product fingerprint+decision cache and rebuilds coverage
from it; results carry operational summary and blockers; Firebird credentials come from a
DPAPI firebird.bin. Setup: read-only SGV Firebird discovery (auto-select only when
unambiguous, wizard chooser otherwise), starts the service, waits for the first heartbeat,
upgrade-in-place mode. All jobs stay read-only (FIREBIRD_WRITES=0). Version 1.1.3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-24 08:47:00 -03:00
co-authored by Claude Sonnet 5
parent 9f9e06e9e9
commit bb663a979c
36 changed files with 1409 additions and 118 deletions
@@ -0,0 +1,24 @@
"""Central processing control + sync bookkeeping per installation.
Revision ID: 0026_central_processing_control
Revises: 0025_tenant_external_cnpj
"""
from alembic import op
import sqlalchemy as sa
revision = "0026_central_processing_control"
down_revision = "0025_tenant_external_cnpj"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("installations", sa.Column("processing_enabled", sa.Boolean(), nullable=False, server_default="true"))
op.add_column("installations", sa.Column("sync_interval_seconds", sa.Integer(), nullable=False, server_default="1800"))
op.add_column("installations", sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("installations", sa.Column("last_sync_changed", sa.Integer(), nullable=True))
def downgrade():
for column in ("last_sync_changed", "last_sync_at", "sync_interval_seconds", "processing_enabled"):
op.drop_column("installations", column)
+46 -2
View File
@@ -10,7 +10,7 @@ from .auth import authenticate, bootstrap
from .classification import ENGINE_VERSION, Classifier
from .config import settings
from .datasets.service import DatasetService
from .jobs import claim_next_job, complete_job, create_job, fail_job
from .jobs import SYNC_KINDS, claim_next_job, complete_job, create_job, fail_job, schedule_automatic_sync
from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, upsert_blocker
from .context_keys import validate_entry
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
@@ -206,8 +206,13 @@ def router(session_factory, bootstrap_token):
session.add(agent); session.flush()
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="HEARTBEAT", message="Agent heartbeat", context={"status": agent.last_status, "agent_version": agent.agent_version})
session.commit()
# Central scheduler: exactly one INITIAL_SYNC after the first heartbeat, then
# one SYNC_INCREMENTAL per interval — idempotent, no local cron/toggle needed.
if agent.enabled:
schedule_automatic_sync(session, agent, installation, snapshot)
logger.info("heartbeat agent_id=%s status=%s last_job_id=%s", agent.id, agent.last_status, payload.get("last_job_id"))
return {"agent_id": agent.id, "installation_id": installation.installation_id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled}
return {"agent_id": agent.id, "installation_id": installation.installation_id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled,
"processing_enabled": bool(installation.processing_enabled), "sync_interval_seconds": installation.sync_interval_seconds}
finally: session.close()
@routes.post("/agents/register")
@@ -332,6 +337,36 @@ def router(session_factory, bootstrap_token):
"attempts": job.attempts, "result_summary": job.result_summary, "error": job.error,
}
def _apply_job_outcome(session, agent, installation, job, result_summary, *, failed):
"""After a job finishes: coverage snapshot (from the Agent's operational
summary), sync bookkeeping and an audit-friendly event. Never stores secrets."""
now = datetime.now(timezone.utc)
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
if snapshot is None:
snapshot = AgentSnapshot(installation_id=installation.id); session.add(snapshot)
snapshot.last_job_status = "FAILED" if failed else "COMPLETED"
snapshot.last_job_at = now
context = {"kind": job.kind}
operational = result_summary.get("operational") if not failed else None
if operational and job.kind in SYNC_KINDS:
total = int(operational.get("TOTAL_PRODUCTS", 0))
complete = int(operational.get("COMPLETE", 0)) + int(operational.get("CORRECTED", 0))
snapshot.total_products = total
snapshot.fiscally_complete = complete
snapshot.safe_blocked = int(operational.get("SAFE_BLOCKED", 0))
snapshot.failed_products = int(operational.get("FAILED_TECHNICAL", 0))
snapshot.coverage_percent = round(100.0 * complete / total, 2) if total else 0.0
snapshot.blockers = {state: int(count) for state, count in operational.items() if state.startswith("BLOCKED_") and count}
snapshot.last_successful_job_at = now
installation.last_sync_at = now
installation.last_sync_changed = int(result_summary.get("products_changed", result_summary.get("products_processed", 0)) or 0)
context.update({"total_products": total, "products_changed": installation.last_sync_changed, "products_processed": result_summary.get("products_processed")})
if failed:
context["error"] = result_summary.get("error")
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if failed else "INFO",
event_type="JOB_FAILED" if failed else "JOB_COMPLETED", message=f"Job {job.kind} {'failed' if failed else 'completed'}", context=context, job_id=job.id)
session.commit()
@routes.post("/jobs")
def create_job_route(payload: dict, identity=Depends(principal)):
session, agent = identity
@@ -353,6 +388,10 @@ def router(session_factory, bootstrap_token):
try:
job = claim_next_job(session, agent)
if job is None: return None
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is not None:
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="JOB_STARTED", message=f"Job {job.kind} started", context={"kind": job.kind}, job_id=job.id)
session.commit()
logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind)
return _job_view(job)
finally: session.close()
@@ -388,6 +427,8 @@ def router(session_factory, bootstrap_token):
for product_code in result_summary.get("resolved_products", []):
resolve_product_blockers(session, agent.tenant_id, product_code, installation_id=installation.id if installation else None)
if not complete_job(session, job, result_summary): raise HTTPException(409, "job is not RUNNING")
if installation is not None:
_apply_job_outcome(session, agent, installation, job, result_summary, failed=False)
logger.info("job completed job_id=%s agent_id=%s result=%s", job_id, agent.id, payload.get("result_summary", {}))
return _job_view(session.get(Job, job_id))
finally: session.close()
@@ -399,6 +440,9 @@ def router(session_factory, bootstrap_token):
job = session.get(Job, job_id)
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
if not fail_job(session, job, payload.get("error", "unknown error")): raise HTTPException(409, "job is not RUNNING")
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is not None:
_apply_job_outcome(session, agent, installation, job, {"error": str(payload.get("error", "unknown error"))[:500]}, failed=True)
logger.warning("job failed job_id=%s agent_id=%s error=%s", job_id, agent.id, payload.get("error", "unknown error"))
return _job_view(session.get(Job, job_id))
finally: session.close()
+73 -8
View File
@@ -1,31 +1,74 @@
"""Minimal job queue for Agent orchestration: create, atomically claim, complete, fail."""
from datetime import datetime, timezone
"""Job queue for Agent orchestration: create (idempotent per kind), claim one job at
a time per Agent, complete, fail. Also the central sync scheduler helpers.
Every fiscal job is READ-ONLY on the ERP (INITIAL_SYNC, SYNC_INCREMENTAL,
REPROCESS_ALL, REPROCESS_PRODUCTS); automation never authorizes Firebird writes.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from .models import Job
from .models import Installation, Job
_VALID_KINDS = {"REPROCESS_ALL", "REPROCESS_PRODUCTS"}
FULL_KINDS = ("INITIAL_SYNC", "REPROCESS_ALL") # read every product, reclassify all
SYNC_KINDS = FULL_KINDS + ("SYNC_INCREMENTAL",) # jobs that count as "a sync happened"
_VALID_KINDS = set(SYNC_KINDS) | {"REPROCESS_PRODUCTS"}
_MAX_ERROR_LENGTH = 2000
STALE_RUNNING_AFTER = timedelta(hours=1) # an Agent that died mid-job must not block the queue forever
def _aware(value):
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
def create_job(session, agent, kind, payload=None):
"""Create a job. Sync-type jobs are idempotent: if this Agent already has a
PENDING/RUNNING job that covers the request, that job is returned instead
(no duplicates, never two full jobs at once)."""
if kind not in _VALID_KINDS:
raise ValueError(f"unsupported job kind: {kind}")
if kind in SYNC_KINDS:
# A full job subsumes an incremental one, not the other way round: asking for a
# full reprocess while only an incremental is queued must still queue the full.
blocking = FULL_KINDS if kind in FULL_KINDS else SYNC_KINDS
existing = session.scalar(select(Job).where(Job.agent_id == agent.id, Job.kind.in_(blocking), Job.status.in_(("PENDING", "RUNNING"))).order_by(Job.id).limit(1))
if existing is not None:
return existing
job = Job(agent_id=agent.id, kind=kind, status="PENDING", payload=payload or {})
session.add(job)
session.commit()
return job
def recover_stale_running(session, agent, now=None):
now = now or datetime.now(timezone.utc)
cutoff = now - STALE_RUNNING_AFTER
stale = session.scalars(select(Job).where(Job.agent_id == agent.id, Job.status == "RUNNING")).all()
changed = False
for job in stale:
if job.started_at is not None and _aware(job.started_at) < cutoff:
job.status, job.finished_at, job.error = "FAILED", now, "stale: Agent stopped reporting while RUNNING"
changed = True
if changed:
session.commit()
def claim_next_job(session, agent):
"""Atomically claim the oldest PENDING job for this agent only.
A single UPDATE...WHERE status='PENDING' guards against two concurrent
pollers (this agent or any other) claiming the same job twice."""
"""Atomically claim the oldest PENDING job for this agent only — and only when
processing is enabled centrally and no other job of this Agent is RUNNING
(one fiscal job at a time per Firebird)."""
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
if installation is not None and not installation.processing_enabled:
return None
recover_stale_running(session, agent)
if session.scalar(select(Job.id).where(Job.agent_id == agent.id, Job.status == "RUNNING").limit(1)) is not None:
return None
candidate = session.scalar(
select(Job.id)
.where(Job.agent_id == agent.id, Job.status == "PENDING")
.order_by(Job.created_at.asc())
.order_by(Job.created_at.asc(), Job.id.asc())
.limit(1)
)
if candidate is None:
@@ -61,3 +104,25 @@ def fail_job(session, job, error):
)
session.commit()
return result.rowcount == 1
def schedule_automatic_sync(session, agent, installation, snapshot, now=None):
"""Called from the Agent heartbeat. Central scheduler, idempotent:
- first heartbeat of an installation with no initial coverage and no sync job
ever created -> exactly ONE INITIAL_SYNC;
- otherwise, if processing is enabled and the last sync attempt is older than
the installation's interval, and nothing is queued/running -> ONE SYNC_INCREMENTAL.
Returns the created job or None."""
if installation is None or not installation.processing_enabled:
return None
now = now or datetime.now(timezone.utc)
sync_jobs = session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind.in_(SYNC_KINDS)).order_by(Job.id.desc())).all()
if any(job.status in ("PENDING", "RUNNING") for job in sync_jobs):
return None
never_synced = not any(job.kind == "INITIAL_SYNC" or job.kind == "REPROCESS_ALL" for job in sync_jobs) and not int(getattr(snapshot, "total_products", 0) or 0)
if never_synced and not sync_jobs:
return create_job(session, agent, "INITIAL_SYNC", {"reason": "INITIAL_SYNC"})
last_attempt = max((_aware(job.finished_at or job.created_at) for job in sync_jobs if (job.finished_at or job.created_at)), default=None)
if last_attempt is not None and (now - last_attempt).total_seconds() >= installation.sync_interval_seconds:
return create_job(session, agent, "SYNC_INCREMENTAL", {"reason": "SCHEDULED"})
return None
+6
View File
@@ -276,6 +276,12 @@ class Installation(Base):
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
last_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Central execution control (no local .env toggle): the Hub decides whether this
# installation's Agent executes jobs, and how often the automatic incremental sync runs.
processing_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
sync_interval_seconds: Mapped[int] = mapped_column(Integer, default=1800, server_default="1800")
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_sync_changed: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+1 -1
View File
@@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds
once published — never a second, independent version number. Fiscal semantics
are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py,
DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump."""
GTI_FISCAL_VERSION = "1.1.2"
GTI_FISCAL_VERSION = "1.1.3"
API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x
+43 -1
View File
@@ -279,9 +279,51 @@ def router(session_factory):
for event in events:
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25)
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent)})
finally: session.close()
def _ops_view(session, installation, agent):
"""Processing status shown on the Agent detail (no clutter: 5 facts)."""
last_job = session.scalar(select(Job).where(Job.agent_id == agent.id).order_by(Job.id.desc()).limit(1))
last_sync = _aware(installation.last_sync_at)
next_sync = None
if installation.processing_enabled and last_sync is not None:
next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds)
return {"enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
"last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None,
"last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id} if last_job else None}
async def _installation_action(request, agent_id, action):
session, auth_data, redirect = page_auth(request)
if redirect: return redirect
try:
values = await form_values(request); web_session, admin = auth_data
require_csrf(web_session, values.get("csrf_token"))
agent = session.get(Agent, agent_id)
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
if action == "pause": installation.processing_enabled = False
elif action == "resume": installation.processing_enabled = True
elif action == "sync-now":
job = create_job(session, agent, "SYNC_INCREMENTAL", {"reason": "MANUAL_SYNC_NOW", "requested_by": admin.id})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="SYNC_NOW", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "kind": job.kind})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"PROCESSING_{action.upper().replace('-', '_')}", target_type="installation", target_id=installation.installation_id, context={"processing_enabled": installation.processing_enabled}) if action in ("pause", "resume") else None
session.commit()
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/sync-now")
async def sync_now(request: Request, agent_id: int):
return await _installation_action(request, agent_id, "sync-now")
@routes.post("/agents/{agent_id}/pause")
async def pause_processing(request: Request, agent_id: int):
return await _installation_action(request, agent_id, "pause")
@routes.post("/agents/{agent_id}/resume")
async def resume_processing(request: Request, agent_id: int):
return await _installation_action(request, agent_id, "resume")
@routes.get("/agents/{agent_id}/reprocess-confirmation", response_class=HTMLResponse)
def reprocess_confirmation(request: Request, agent_id: int):
session, auth_data, redirect = page_auth(request)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-hub"
version = "1.1.2"
version = "1.1.3"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.115,<1",
+5
View File
@@ -4,6 +4,11 @@
<div class="detail-head"><div><p class="eyebrow">{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO</p><h1>{{ detail.display_name or detail.hostname or detail.installation_id }}</h1><p class="muted">{{ detail.hostname or "hostname indisponível" }}</p></div><div class="badge-stack"><span class="badge {{ detail.technical_health|lower }} large">Saúde técnica: {{ detail.technical_health_label }}</span><span class="badge fiscal-{{ detail.fiscal_status|lower }} large">Status fiscal: {{ detail.fiscal_status_label }}</span></div></div>
<section class="cards"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products if detail.total_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete if detail.fiscally_complete is not none else "Não informado" }}</strong></article><article class="metric"><span>Bloqueados com segurança</span><strong>{{ detail.safe_blocked if detail.safe_blocked is not none else "Não informado" }}</strong></article><article class="metric"><span>Falhas técnicas</span><strong>{{ detail.failed_products if detail.failed_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Não classificados</span><strong>{{ detail.unclassified_products if detail.unclassified_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Cobertura fiscal</span>{% if detail.fiscal_completion_percent is none %}<strong>Não informado</strong><small>nenhum snapshot recebido</small>{% else %}<strong>{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ detail.fiscally_complete }} / {{ detail.total_products }} completos</small>{% endif %}</article></section>
{% if detail.total_products is none %}<p class="coverage-status">Cobertura ainda não informada: nenhum snapshot recebido deste servidor.</p>{% elif detail.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada para este servidor.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.</p>{% endif %}
<section class="panel ops-panel"><div class="panel-title"><div><h2>Processamento</h2><p class="muted">Controlado pelo Hub — nenhuma configuração local.</p></div><div class="ops-actions">
<form method="post" action="/agents/{{ detail.agent_id }}/sync-now" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" {% if not ops.enabled %}disabled title="Processamento pausado"{% endif %}>Sincronizar agora</button></form>
<a class="botao" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a>
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="secondary">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit">Retomar processamento</button></form>{% endif %}
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd><dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?job_id={{ ops.last_job.id }}">{{ ops.last_job.kind }}</a> · {{ ops.last_job.status }}{% else %}nenhum{% endif %}</dd></dl></section>
<nav class="tabs" aria-label="Seções da instalação">{% for name,label in [("overview","Resumo"),("fiscal","Fiscal"),("blockers","Blockers"),("jobs","Jobs"),("events","Eventos")] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ label }}</a>{% endfor %}</nav>
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2><a class="secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation">Reprocessar tudo</a></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "Não informado" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "Não informado" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "Não informado" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "Não informado" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "Não informado" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "Não informado" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version_display }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_at }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · <a href="/jobs?job_id={{ event.job_id }}">Job {{ event.job_id }}</a>{% endif %}{% if event.product_code %} · <a href="/blockers?agent_id={{ detail.agent_id }}&amp;product_code={{ event.product_code|urlencode }}">Produto {{ event.product_code }}</a>{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
+1 -1
View File
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
assert body["version"] == "1.1.2" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
assert body["version"] == "1.1.3" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch):
+5
View File
@@ -83,6 +83,11 @@ def test_job_api_end_to_end_flow():
assert heartbeat.status_code == 200
assert heartbeat.json()["enabled"] is True
# Zero-touch: the first heartbeat makes the Hub queue exactly one INITIAL_SYNC by itself.
initial = client.get("/v1/jobs/next", headers=headers).json()
assert initial["kind"] == "INITIAL_SYNC"
assert client.post(f"/v1/jobs/{initial['job_id']}/complete", json={"result_summary": {}}, headers=headers).json()["status"] == "COMPLETED"
created = client.post("/v1/jobs", json={"kind": "REPROCESS_PRODUCTS", "payload": {"codigos": ["000026"]}}, headers=headers)
assert created.status_code == 200
job_id = created.json()["job_id"]
+196
View File
@@ -0,0 +1,196 @@
"""Zero-touch control plane: central scheduler (INITIAL_SYNC once, SYNC_INCREMENTAL
per interval), single-job concurrency, central pause/resume, coverage snapshot from
the job result, idempotency. Everything is Hub-side — no local toggle involved."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from central.app.jobs import claim_next_job, create_job, recover_stale_running
from central.app.models import Agent, AgentSnapshot, AuditEvent, Installation, Job
from central.tests.test_jobs import _client
H = {"Authorization": "Bearer test-token"}
INST = "00000000-0000-4000-8000-0000000000aa"
OPERATIONAL = {"COMPLETE": 480, "CORRECTED": 0, "BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10, "BLOCKED_COMPANY_CONTEXT": 0, "BLOCKED_SCHEMA": 0,
"FAILED_TECHNICAL": 0, "TOTAL_PRODUCTS": 521, "SAFE_BLOCKED": 41}
def hb(client):
return client.post("/v1/agents/heartbeat", json={"installation_id": INST, "status": "IDLE", "agent_version": "1.1.3"}, headers=H)
def jobs(engine, kind=None):
with Session(engine) as s:
q = select(Job).order_by(Job.id)
if kind: q = q.where(Job.kind == kind)
return s.scalars(q).all()
def finish(client, engine, summary=None):
job = client.get("/v1/jobs/next", headers=H).json()
assert job, "expected a claimable job"
body = {"result_summary": summary or {"operational": OPERATIONAL, "products_processed": 521, "products_changed": 521, "blocked_products": [], "resolved_products": []}}
assert client.post(f"/v1/jobs/{job['job_id']}/complete", json=body, headers=H).status_code == 200
return job
def age_last_sync(engine, seconds):
with Session(engine) as s:
for job in s.scalars(select(Job)).all():
job.finished_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
job.created_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
s.commit()
def test_first_heartbeat_creates_exactly_one_initial_sync():
client, engine = _client()
body = hb(client).json()
assert body["processing_enabled"] is True and body["sync_interval_seconds"] == 1800
for _ in range(4):
hb(client)
initial = jobs(engine, "INITIAL_SYNC")
assert len(initial) == 1 and initial[0].status == "PENDING" and initial[0].payload["reason"] == "INITIAL_SYNC"
def test_initial_sync_completion_sets_coverage_and_invariant():
client, engine = _client()
hb(client); finish(client, engine)
with Session(engine) as s:
snap = s.scalar(select(AgentSnapshot)); inst = s.scalar(select(Installation))
assert (snap.total_products, snap.fiscally_complete, snap.safe_blocked, snap.failed_products) == (521, 480, 41, 0)
assert snap.fiscally_complete + snap.safe_blocked + snap.failed_products == snap.total_products # coverage invariant closes
assert snap.blockers == {"BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10}
assert inst.last_sync_at is not None and inst.last_sync_changed == 521
for _ in range(3):
hb(client)
assert len(jobs(engine)) == 1 # no second initial sync, and not due for an incremental yet
def test_incremental_sync_scheduled_once_per_interval():
client, engine = _client()
hb(client); finish(client, engine)
age_last_sync(engine, 1801)
hb(client); hb(client); hb(client)
inc = jobs(engine, "SYNC_INCREMENTAL")
assert len(inc) == 1 and inc[0].payload["reason"] == "SCHEDULED"
assert not jobs(engine, "REPROCESS_ALL") # full reprocess never runs automatically
def test_central_pause_stops_scheduling_and_claiming_but_heartbeat_continues():
client, engine = _client()
hb(client)
with Session(engine) as s:
s.scalar(select(Installation)).processing_enabled = False; s.commit()
body = hb(client).json()
assert body["processing_enabled"] is False and body["enabled"] is True # heartbeat keeps working
assert client.get("/v1/jobs/next", headers=H).json() is None # the PENDING initial sync is NOT handed out while paused
with Session(engine) as s:
s.scalar(select(Installation)).processing_enabled = True; s.commit()
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "INITIAL_SYNC" # resume: jobs flow again
def test_only_one_job_runs_at_a_time_and_queue_is_fifo():
client, engine = _client()
hb(client)
with Session(engine) as s:
agent = s.scalar(select(Agent))
create_job(s, agent, "REPROCESS_PRODUCTS", {"codigos": ["1"]})
first = client.get("/v1/jobs/next", headers=H).json()
assert first["kind"] == "INITIAL_SYNC"
assert client.get("/v1/jobs/next", headers=H).json() is None # second job waits: one fiscal job per Firebird
client.post(f"/v1/jobs/{first['job_id']}/complete", json={"result_summary": {}}, headers=H)
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
def test_reprocess_all_and_sync_now_are_not_duplicated():
client, engine = _client()
hb(client); finish(client, engine)
with Session(engine) as s:
agent = s.scalar(select(Agent))
a = create_job(s, agent, "SYNC_INCREMENTAL", {"reason": "MANUAL_SYNC_NOW"}); b = create_job(s, agent, "SYNC_INCREMENTAL")
assert a.id == b.id # double click = one job
full1 = create_job(s, agent, "REPROCESS_ALL"); full2 = create_job(s, agent, "REPROCESS_ALL")
assert full1.id == full2.id and full1.id != a.id # a full reprocess still queues behind the incremental, never twice
assert create_job(s, agent, "SYNC_INCREMENTAL").id == a.id
def test_stale_running_job_does_not_block_the_queue_forever():
client, engine = _client()
hb(client)
job = client.get("/v1/jobs/next", headers=H).json()
with Session(engine) as s:
row = s.get(Job, job["job_id"]); row.started_at = datetime.now(timezone.utc) - timedelta(hours=2); s.commit()
create_job(s, s.scalar(select(Agent)), "REPROCESS_PRODUCTS", {"codigos": ["1"]})
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
with Session(engine) as s:
assert s.get(Job, job["job_id"]).status == "FAILED"
def test_job_events_recorded_without_secrets():
client, engine = _client()
hb(client); finish(client, engine)
with Session(engine) as s:
from central.app.models import AgentEvent
types = {e.event_type for e in s.scalars(select(AgentEvent)).all()}
assert {"HEARTBEAT", "JOB_STARTED", "JOB_COMPLETED"} <= types
def test_failed_job_records_failure_and_does_not_reschedule_immediately():
client, engine = _client()
hb(client)
job = client.get("/v1/jobs/next", headers=H).json()
client.post(f"/v1/jobs/{job['job_id']}/fail", json={"error": "Firebird offline"}, headers=H)
for _ in range(3):
hb(client)
assert len(jobs(engine)) == 1 # backoff = the interval; no error loop against a broken Firebird
with Session(engine) as s:
assert s.scalar(select(AgentSnapshot)).last_job_status == "FAILED"
# ---------- panel: central control buttons + status ----------
def _panel():
from central.tests.test_control_panel_pages import _panel_client
client, agent_id, _ = _panel_client()
return client, agent_id, client._session_factory
def test_panel_shows_processing_status_and_the_operational_buttons():
client, agent_id, _ = _panel()
body = client.get(f"/agents/{agent_id}").text
for label in ("Sincronizar agora", "Reprocessar tudo", "Pausar processamento", "Última sincronização", "Sincronização agendada"):
assert label in body
assert "Ativo" in body and "a cada 30 min" in body and "Retomar processamento" not in body
def test_panel_sync_now_pause_resume_are_csrf_protected_audited_and_idempotent():
client, agent_id, factory = _panel()
csrf = client.cookies.get("gti_csrf")
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": "wrong"}).status_code == 403
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303 # double click
with factory() as s:
incs = s.scalars(select(Job).where(Job.kind == "SYNC_INCREMENTAL")).all()
assert len(incs) == 1 and incs[0].payload["reason"] == "MANUAL_SYNC_NOW"
client.post(f"/agents/{agent_id}/pause", data={"csrf_token": csrf})
with factory() as s:
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is False
paused = client.get(f"/agents/{agent_id}").text
assert "Pausado" in paused and "Retomar processamento" in paused and "disabled" in paused # Sync-now disabled while paused
client.post(f"/agents/{agent_id}/resume", data={"csrf_token": csrf})
with factory() as s:
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is True
actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
assert {"SYNC_NOW", "PROCESSING_PAUSE", "PROCESSING_RESUME"} <= set(actions)
def test_panel_controls_require_login():
from fastapi.testclient import TestClient
from central.app.main import create_app
from central.tests.test_control_plane import seed, setup_db
factory = setup_db(); agent_id, _ = seed(factory)
anon = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
for action in ("sync-now", "pause", "resume"):
assert anon.post(f"/agents/{agent_id}/{action}", data={}, follow_redirects=False).status_code == 303 # bounced to login, nothing executed