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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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 }}&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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user