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
+71 -2
View File
@@ -13,9 +13,11 @@ Policy enforced here (mirrors CLAUDE.md):
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
import winreg
from pathlib import Path
@@ -64,7 +66,7 @@ def enroll(install_code: str, *, hub: str | None = None) -> dict:
return response.json()
def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None:
def write_agent_env(install_dir_path: Path, enroll_result: dict, firebird_path: str = "") -> None:
"""Write the non-secret agent.env. The permanent credential goes to
credentials.bin (DPAPI), never here. FIREBIRD_WRITES=0 / DRY_RUN always."""
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1
@@ -76,7 +78,8 @@ def write_agent_env(install_dir_path: Path, enroll_result: dict) -> None:
(f"INSTALLATION_ID={enroll_result.get('installation_id') or ''}"),
("COMPANY_ID="),
("COMPANY_UF="),
("FIREBIRD_DATABASE_PATH="),
(f"FIREBIRD_DATABASE_PATH={firebird_path}"),
# Firebird user/password live ONLY in the DPAPI-protected firebird.bin (never here, never sent to the Hub).
("FB_USER="),
("FB_PASSWORD="),
("FISCAL_MODE=DRY_RUN"),
@@ -103,6 +106,72 @@ def write_credential(install_dir_path: Path, agent_token: str) -> None:
_write(install / "credentials.bin", agent_token)
def write_firebird_credential(install_dir_path: Path, user: str, password: str) -> None:
"""Firebird user/password -> firebird.bin via DPAPI LocalMachine + ACL (fail closed).
Local only: this never reaches the Hub and never lands in agent.env."""
from gti_fiscal_agent.credential_store import write_credential as _write
install = Path(install_dir_path)
install.mkdir(parents=True, exist_ok=True)
_write(install / "firebird.bin", json.dumps({"user": user, "password": password}))
def detect_existing(install_dir_path: Path) -> dict | None:
"""An already-enrolled installation (identity + DPAPI credential present)."""
install = Path(install_dir_path)
env_file, cred = install / "agent.env", install / "credentials.bin"
if not env_file.exists() or not cred.exists():
return None
values = {}
for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
key, _, value = line.partition("=")
values[key.strip()] = value.strip()
if not values.get("AGENT_ID"):
return None
return {"agent_id": values["AGENT_ID"], "installation_id": values.get("INSTALLATION_ID"), "hub_url": values.get("GTI_HUB_URL") or hub_url(), "env": values}
def upgrade_agent_env(install_dir_path: Path, agent_version: str) -> None:
"""Migrate an existing agent.env to the central-control model: drop the legacy local
AGENT_JOBS_ENABLED toggle, refresh AGENT_VERSION, keep every other value (identity, Firebird path...)."""
env_file = Path(install_dir_path) / "agent.env"
kept, seen_version = [], False
for line in env_file.read_text(encoding="utf-8", errors="replace").splitlines():
key = line.partition("=")[0].strip()
if key == "AGENT_JOBS_ENABLED":
continue
if key == "AGENT_VERSION":
line, seen_version = f"AGENT_VERSION={agent_version}", True
kept.append(line)
if not seen_version:
kept.append(f"AGENT_VERSION={agent_version}")
env_file.write_text(chr(10).join(kept) + chr(10), encoding="utf-8")
def wait_service_state(wanted: str, timeout: float = 45.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if wanted in _sc(["query", SERVICE_NAME]).stdout:
return True
time.sleep(1.5)
return False
def wait_for_heartbeat(agent_token: str, hub: str, timeout: float = 120.0) -> bool:
"""True once the Hub sees this Agent ONLINE (first heartbeat after the service started)."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
response = httpx.get((hub or hub_url()).rstrip("/") + "/v1/agents/me", headers={"Authorization": f"Bearer {agent_token}"}, timeout=10)
if response.status_code == 200 and response.json().get("status") == "ONLINE":
return True
except Exception: # noqa: BLE001
pass
time.sleep(4)
return False
def _sc(args: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(["sc.exe", *args], capture_output=True, text=True)