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
+1 -1
View File
@@ -74,7 +74,7 @@ if (-not $SkipSetup) {
$ExecutorData = (Join-Path $Root "scripts\firebird_write_executor.py") + ";" + "scripts"
Invoke-Step "Build Setup.exe (onefile, uac-admin)" -Body {
& $py -m PyInstaller @Common @Paths @Hidden @Excludes "--onefile" "--uac-admin" "--name" "GTIFiscalAgent-Setup" `
"--add-data" $ServiceData "--add-data" $UninstallData "--add-data" $ExecutorData `
"--add-data" $ServiceData "--add-data" $UninstallData "--add-data" $ExecutorData "--add-data" $VersionData `
(Join-Path $Root "installer\setup_main.py")
}
}
+83
View File
@@ -0,0 +1,83 @@
"""Real zero-touch runtime E2E against the local dev Hub + a real Firebird test database.
No manual step between heartbeat and result: a brand-new installation identity sends its
first heartbeat, the Hub queues exactly one INITIAL_SYNC, the Agent claims and executes it
(READ-ONLY), coverage/blockers land on the Hub; then the central scheduler queues a
SYNC_INCREMENTAL by itself (interval shortened to 1 s for the test) which must reprocess
only the delta (nothing changed -> 0), and finally a central pause stops job execution while
heartbeats continue.
Usage (dev Hub on :8000, .env with FB credentials): python scripts/e2e_zero_touch_local.py
"""
import json
import os
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT), str(ROOT / "agent" / "src"), str(ROOT / "agent" / "scripts")]
from dotenv import dotenv_values # noqa: E402
env_file = dotenv_values(ROOT / ".env")
HUB = "http://localhost:8000"
def psql(sql):
out = subprocess.run(["docker", "exec", "projeto-postgres-1", "psql", "-U", "gti", "-d", "gti_fiscal", "-At", "-F", "|", "-c", sql], capture_output=True, text=True)
return out.stdout.strip()
def main():
import run_agent_service as service
tmp = Path(tempfile.mkdtemp(prefix="gti_zt_"))
# A brand-new Agent (own tenant + token), like a freshly enrolled installation — never the shared dev agent.
reg = subprocess.run(["docker", "exec", "projeto-fiscal-hub-1", "python", "central/scripts/register_agent.py", "--company", f"e2e-zero-touch-{int(time.time())}", "--name", "zt"], capture_output=True, text=True).stdout
token = next(line.split("=", 1)[1] for line in reg.splitlines() if line.strip().startswith("AGENT_TOKEN="))
installation = str(uuid.uuid4())
os.environ.update({
"GTI_HUB_URL": HUB, "AGENT_TOKEN": token, "AGENT_ID": "dev", "INSTALLATION_ID": installation,
"FIREBIRD_DATABASE_PATH": env_file["FIREBIRD_DATABASE_PATH"], "FB_USER": env_file.get("FB_USER", "SYSDBA"), "FB_PASSWORD": env_file["FB_PASSWORD"],
"FIREBIRD_CLIENT_LIBRARY": env_file.get("FIREBIRD_CLIENT_LIBRARY", ""), "FIREBIRD_WRITES": "0", "FISCAL_MODE": "DRY_RUN",
"AGENT_STATE_DB": str(tmp / "state.sqlite"), "AGENT_LOG_PATH": str(tmp / "agent.log"), "POLL_INTERVAL_SECONDS": "0.2",
"AGENT_JOBS_ENABLED": "0", # legacy local toggle deliberately set to 0: must be ignored (control is central)
})
config = service.Config(os.environ)
service.setup_logging(config.log_path)
print("INSTALLATION_ID=", installation, "FIREBIRD_WRITES=", config.firebird_writes)
def run(n):
service.run(config, os.environ, max_iterations=n)
# 1) first heartbeat -> INITIAL_SYNC by the Hub -> claimed + executed in the same loop
run(4)
jobs = psql(f"select j.id,j.kind,j.status,j.result_summary->>'products_total',j.result_summary->>'products_changed' from jobs j join installations i on i.agent_id=j.agent_id where i.installation_id='{installation}' order by j.id")
print("AFTER_INITIAL:\n" + jobs)
snap = psql(f"select s.total_products,s.fiscally_complete,s.safe_blocked,s.failed_products,s.blockers from agent_snapshots s join installations i on i.id=s.installation_id where i.installation_id='{installation}'")
print("SNAPSHOT=", snap)
blockers = psql(f"select count(*) from blocked_products b join installations i on i.id=b.installation_id where i.installation_id='{installation}'")
print("BLOCKERS_TOTAL=", blockers)
# 2) central scheduler: shorten the interval, next heartbeat queues one SYNC_INCREMENTAL by itself
psql(f"update installations set sync_interval_seconds=1 where installation_id='{installation}'")
time.sleep(2)
run(4)
print("AFTER_INCREMENTAL:\n" + psql(f"select j.id,j.kind,j.status,j.payload->>'reason',j.result_summary->>'products_total',j.result_summary->>'products_changed' from jobs j join installations i on i.agent_id=j.agent_id where i.installation_id='{installation}' order by j.id"))
# 3) central pause: heartbeat continues, nothing is claimed
psql(f"update installations set processing_enabled=false where installation_id='{installation}'")
before = psql("select count(*) from jobs where status='RUNNING'")
time.sleep(2)
run(3)
print("PAUSED_RUNNING_JOBS=", before, "LAST_STATUS=", psql(f"select last_status from installations where installation_id='{installation}'"))
print("WRITES:", psql(f"select coalesce(sum((result_summary->>'writes_attempted')::int),0), coalesce(sum((result_summary->>'writes_succeeded')::int),0) from jobs j join installations i on i.agent_id=j.agent_id where i.installation_id='{installation}'"))
print("LOCAL_ipi_write_log_rows=", __import__("sqlite3").connect(tmp / "state.sqlite").execute("select count(*), sum(write_status='DRY_RUN_ONLY') from ipi_write_log").fetchone())
if __name__ == "__main__":
main()