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
+58 -12
View File
@@ -31,10 +31,12 @@ sys.path.insert(0, str(PROJECT_ROOT)) # ships central/app engine modules read-o
from gti_fiscal_agent.client import HubClient # noqa: E402
from gti_fiscal_agent.firebird_reader import check_connection, read_products # noqa: E402
from gti_fiscal_agent.pipeline import run_pipeline # noqa: E402
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1, to_hub_product # noqa: E402
from gti_fiscal_agent.runner import REFERENCE_SCHEMA_V1, product_fingerprint, to_hub_product # noqa: E402
from central.app.operational_state import classify_product_state, summarize_operational_states # noqa: E402
from gti_fiscal_agent.state import AgentState # noqa: E402
_MAX_BACKOFF_SECONDS = 60
SYNC_JOB_KINDS = ("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL") # read-only ERP jobs that refresh coverage
logger = logging.getLogger("gti_fiscal_agent")
@@ -87,6 +89,7 @@ class Config:
self.agent_token = self._resolve_agent_token(env)
self.company_id = env.get("COMPANY_ID", "")
self.company_uf = env.get("COMPANY_UF", "")
_resolve_firebird_credentials(env)
self.database_path = env["FIREBIRD_DATABASE_PATH"]
# FISCAL_MODE gates writes technically: DRY_RUN can never write, whatever FIREBIRD_WRITES says. Unset = legacy
# behaviour (FIREBIRD_WRITES decides). `firebird_writes` below is the EFFECTIVE value used by every write path.
@@ -94,10 +97,12 @@ class Config:
self.fiscal_mode = (env.get("FISCAL_MODE") or ("AUTO_APPLY" if requested else "DRY_RUN")).strip().upper()
self.requested_writes = requested
self.firebird_writes = requested and self.fiscal_mode == "AUTO_APPLY"
jobs_enabled = (env.get("AGENT_JOBS_ENABLED", "1") or "1").strip().upper()
self.agent_jobs_enabled = jobs_enabled not in ("0", "FALSE", "NO")
# Job execution is controlled CENTRALLY (Hub heartbeat: processing_enabled) — there is no
# local toggle, so no installation ever needs its agent.env edited to start/stop work.
# (A legacy AGENT_JOBS_ENABLED line in an old agent.env is deliberately ignored.)
self.agent_jobs_enabled = True
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
self.agent_version = env.get("AGENT_VERSION", "1.1.2")
self.agent_version = env.get("AGENT_VERSION", "1.1.3")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
@@ -125,6 +130,23 @@ class Config:
return ""
def _resolve_firebird_credentials(env):
"""FB_USER/FB_PASSWORD come from the DPAPI-protected firebird.bin written by Setup
(never from a hand-edited agent.env, never sent to the Hub). An explicit FB_PASSWORD in
the environment still wins (dev override)."""
if env.get("FB_PASSWORD"):
return
from gti_fiscal_agent.credential_store import read_credential
path = env.get("GTI_FIREBIRD_CREDENTIAL_FILE") or str(Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "GTI" / "FiscalAgent" / "firebird.bin")
try:
data = json.loads(read_credential(path, env=env))
except Exception as exc: # noqa: BLE001 — absent/unreadable: preflight will fail closed with a clear blocker
logger.info("no Firebird credential file usable: %s", type(exc).__name__)
return
env["FB_USER"], env["FB_PASSWORD"] = data.get("user", ""), data.get("password", "")
class GracefulStop:
def __init__(self):
self.stop = False
@@ -183,23 +205,46 @@ def execute_job(job, config, executor, state, env):
logger.info("job %s: read %d product(s) from Firebird", job.get("job_id"), len(products))
client = HubClient(config.hub_url, config.agent_token)
decisions = client.classify_batch([to_hub_product(p) for p in products])
sync_kind = kind in SYNC_JOB_KINDS
cache = sync_kind and hasattr(state, "remember_decision")
# SYNC_INCREMENTAL = delta: only products whose source fingerprint changed (or are new) are
# re-classified by the Hub; INITIAL_SYNC / REPROCESS_ALL / REPROCESS_PRODUCTS process everything they read.
if kind == "SYNC_INCREMENTAL" and cache:
to_process = [p for p in products if state.cached_fingerprint(p["CODIGO"]) != product_fingerprint(p)]
else:
to_process = products
decisions = client.classify_batch([to_hub_product(p) for p in to_process])
write_connection = None
if config.firebird_writes:
write_connection = open_write_connection(env, config.database_path)
try:
summary = run_pipeline(products, decisions, state, config.firebird_writes, write_connection, executor, config.database_path, authorized_path=config.authorized_write_database_path)
summary = run_pipeline(to_process, decisions, state, config.firebird_writes, write_connection, executor, config.database_path, authorized_path=config.authorized_write_database_path)
finally:
if write_connection is not None:
write_connection.close()
coverage = build_job_coverage(env, client, products, decisions) if kind == "REPROCESS_ALL" else None
operational = coverage = None
if sync_kind:
if cache:
for product, decision in zip(to_process, decisions):
state.remember_decision(product["CODIGO"], product_fingerprint(product), decision)
state.forget_missing([p["CODIGO"] for p in products])
all_decisions = [state.cached_decision(p["CODIGO"]) or {} for p in products]
else:
all_decisions = decisions
operational = summarize_operational_states([classify_product_state(p, d) for p, d in zip(products, all_decisions)])
coverage = build_job_coverage(env, client, products, all_decisions)
result_summary = {
"coverage": coverage,
"operational": operational,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.1.2",
"release_version": env.get("AGENT_VERSION") or "1.1.3",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
"blocked_products": summary["blocked_products"],
"resolved_products": summary["resolved_products"],
"writes_attempted": summary["writes_attempted"],
"writes_succeeded": summary["writes_succeeded"],
"writes_failed": summary["writes_failed"],
@@ -275,9 +320,10 @@ def run(config, env, *, max_iterations=None, stopper=None):
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
if hasattr(client, "heartbeat_metadata"):
client.heartbeat_metadata(heartbeat_payload)
hb_response = client.heartbeat_metadata(heartbeat_payload)
else:
client.heartbeat("IDLE", schema_fingerprint=config.schema_fingerprint, agent_version=config.agent_version, last_job_id=last_job_id)
hb_response = client.heartbeat("IDLE", schema_fingerprint=config.schema_fingerprint, agent_version=config.agent_version, last_job_id=last_job_id)
processing_enabled = not (isinstance(hb_response, dict) and hb_response.get("processing_enabled") is False)
if was_down:
logger.info("reconnected to Hub after %ss backoff", backoff)
was_down = False
@@ -289,7 +335,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
backoff = min(backoff * 2, _MAX_BACKOFF_SECONDS)
continue
if not config.agent_jobs_enabled:
if not processing_enabled: # central pause: heartbeat keeps flowing, no job is claimed or executed
time.sleep(config.poll_interval_seconds)
continue
@@ -331,7 +377,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.2")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.1.3")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":