Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
# GTI Fiscal — Pilot Deployment Guide
Living document for operating a real pilot: one Hub, one or more client
Agent installations. See `docs/OPERATIONS.md` for the day-to-day command
reference (creating jobs, checking status); this document is about getting a
new environment stood up and kept alive.
## RC1 (1.1.0) — supported path
```
Hub central (Docker: Hub + PostgreSQL, deploy/hub)
^ HTTPS (TLS terminated by a reverse proxy)
|
Agent Windows (service "GTIFiscalAgent", deploy/agent-windows)
| local read-only connection
SGV Firebird (the client's C000025 / C000004)
```
The Hub **never** accesses Firebird, never stores Firebird credentials and never holds the client's database path. Only the Agent, on the client's Windows host, opens
Firebird. RC1 pilots run `FISCAL_MODE=DRY_RUN` + `FIREBIRD_WRITES=0`: the Agent is technically unable to write (no write connection is ever opened, the executor is never called).
### Hub (GTI infrastructure)
1. `cd deploy/hub && cp .env.example .env` — set `POSTGRES_PASSWORD` (`openssl rand -hex 24`), `BASE_URL` (public HTTPS URL). Leave `HUB_BIND=127.0.0.1` and publish it through the reverse proxy.
2. `docker compose -f docker-compose.prod.yml --env-file .env up -d --build` — PostgreSQL (internal network only, persistent volume, healthcheck), Hub (migrations at start, healthcheck, `restart: unless-stopped`, json-file logs 10 MB x 5).
3. Seed the validated datasets once: on the development machine `sh export_datasets_seed.sh > gti_datasets_seed.dump`; on the Hub host `sh seed_datasets.sh gti_datasets_seed.dump` (refuses if `datasets` is not empty; reference tables only, never tenants/agents/jobs).
4. Register the Agent: `docker compose -f docker-compose.prod.yml exec fiscal-hub python central/scripts/register_agent.py --company "<Client>" --name "<installation>"` — the token is printed once; store it only in the Agent's local `.env`.
5. Health: `GET /health` (Postgres included), `GET /version` (release/engine/decision versions). `APP_ENV=production` refuses to start when `AGENT_DEV_TOKEN` is set.
6. Backup/restore: `sh backup.sh` (pg_dump -Fc into `BACKUP_DIR`, keeps 14) and `sh restore.sh <dump>` (asks for the word RESTORE, stops the Hub, restores, restarts; migrations rerun at start).
### Agent (client host, PowerShell as Administrator, inside the extracted `GTIFiscalAgent-<version>` folder)
Build the package on the release machine: `python scripts/build_agent_package.py` -> `dist/GTIFiscalAgent-<version>.zip` (the version is `release/VERSION`) (+ `.sha256`; secret-scanned, no `.env`).
| step | command |
|---|---|
| install | `.\gti-agent.ps1 install` (venv + dependencies + service registration; does not start) |
| configure | `.\gti-agent.ps1 configure` (prompts; secrets never echoed) or `configure -FromTemplate .\pilot-construcao.env.example` |
| preflight | `.\gti-agent.ps1 preflight` -> `PREFLIGHT=PASS|FAIL` + blockers |
| start / stop / restart | `.\gti-agent.ps1 start` (refuses when preflight fails) |
| status | `.\gti-agent.ps1 status` |
| uninstall | `.\gti-agent.ps1 uninstall [-Purge]` |
### Agent configuration (per installation, `.env` in the install folder)
`GTI_HUB_URL`, `AGENT_ID`, `AGENT_TOKEN`; `COMPANY_CODE` / `COMPANY_CNPJ` (company selection), `COMPANY_CRT_SOURCE` = `AUTO` | `EMISSOR` | `C000004`; `COMPANY_UF` only as an explicit
override (it never replaces the resolver: it must agree with what the database proves); `FIREBIRD_DATABASE_PATH`, `FB_USER` (read-only account), `FB_PASSWORD`; `FISCAL_MODE=DRY_RUN`, `FIREBIRD_WRITES=0`;
`POLL_INTERVAL_SECONDS`, `AGENT_VERSION`. `AUTO` resolves the CRT only when EMISSOR and C000004 agree; a conflict makes the preflight FAIL until `EMISSOR` or `C000004` is chosen explicitly.
The first row of C000004 is never used to pick the company.
### Preflight (`agent/scripts/preflight.py`, also enforced at service start)
HUB_REACHABLE, AUTH, HUB_VERSION_COMPATIBLE (major.minor), FIREBIRD_REACHABLE, FIREBIRD_VERSION_ODS, C000004, C000025, CAPABILITIES (required: NCM, IPI, CEST), COMPANY_CONTEXT (UF, CRT),
SCHEMA_TYPE_GUARDS, SQLITE, LOG_DIRECTORY, FISCAL_MODE, FIREBIRD_WRITES (`DRY_RUN` + `FIREBIRD_WRITES=1` is a misconfiguration -> FAIL).
### Pilot profiles
`deploy/agent-windows/pilot-construcao.env.example` (first pilot: company 000001, UF BA, CRT 1, `COMPANY_CRT_SOURCE=AUTO`, DRY_RUN). Planned, **not installed**: `profiles/mmmodas|rincao|uniao.env.example`
(`COMPANY_CRT_SOURCE=EMISSOR` required: EMISSOR and C000004 disagree on the CRT). Never reuse the path of a local database copy as the client's real path.
### Zero-write proof
`python scripts/prove_zero_write.py snapshot <FDB> before.json` ... run the pilot ... `snapshot <FDB> after.json` ... `compare before.json after.json` (database DML counters, SHA-256 of every row of C000025/C000004, row counts).
---
The sections below are the previous manual instructions (still accurate for development); the RC1 scripts above are the supported way to deploy.
## Architecture
```
┌────────────────────────────┐
│ Hub server (GTI/Proxmox) │
│ Docker Compose: │
│ - postgres (data volume) │
│ - fiscal-hub (FastAPI) │
└─────────────┬────────────────┘
│ HTTPS (reverse proxy terminates TLS)
┌────────────────────────┼────────────────────────┐
│ │ │
┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│ Client A — Agent │ │ Client B — Agent │ │ Client C — Agent │
│ Windows Service │ │ Windows Service │ │ Windows Service │
│ + local Firebird │ │ + local Firebird │ │ + local Firebird │
│ + local SQLite audit│ │ + local SQLite audit│ │ + local SQLite audit│
└────────────────────┘ └────────────────────┘ └────────────────────┘
```
The Hub never has Firebird credentials and never opens a Firebird
connection. Each Agent has its own token and only ever reads/writes the one
Firebird database it was configured for.
## Hub server requirements
- Docker + Docker Compose v2.
- Enough disk for the Postgres volume (starts small; grows with
`classification_cache`/`jobs` history — no large binary data).
- One inbound port for the Hub API (default `8000`, or whatever your reverse
proxy forwards to it). Postgres itself does **not** need a host port —
`docker-compose.yml` intentionally does not publish `5432`; only containers
on the compose network reach it.
## Deploying the Hub
```bash
git clone <repo> && cd Projeto
cp .env.example .env
# Edit .env: set a real POSTGRES_PASSWORD, leave AGENT_DEV_TOKEN empty,
# set APP_ENV=production, BASE_URL to the public HTTPS URL.
docker compose up -d postgres fiscal-hub
curl http://localhost:8000/health # {"status": "ok"}
```
`fiscal-hub`'s container command runs `alembic upgrade head` before starting
`uvicorn`, so schema migrations apply automatically on every deploy/restart.
Both services have `restart: unless-stopped` and Docker healthchecks
(`pg_isready` for Postgres, an HTTP GET to `/health` for the Hub).
**Never** put `AGENT_DEV_TOKEN` in a production `.env` — it auto-bootstraps
an agent from a fixed shared token on every request, fine for local dev,
wrong for a pilot with real client secrets. Leave it empty; register real
Agents with `central/scripts/register_agent.py` (see below).
### Persistence
- Postgres data lives in the named volume `postgres_data`. Back it up with
`docker exec <postgres-container> pg_dump -U gti gti_fiscal > backup.sql`
(or `pg_dumpall` for the whole cluster) on whatever schedule your
operations policy requires.
- Always run `alembic upgrade head` (the container does this automatically)
rather than hand-editing the schema. An **empty** database is initialized
by that same command — never with `alembic stamp` or `create_all`. `0001` is
an explicit, frozen baseline (it used to call `Base.metadata.create_all()`,
which made `0002` collide on `datasets.raw_sha256` on any fresh database);
`central/tests/test_migration_baseline.py` keeps that from coming back.
- `0019` repairs `agents.created_at/updated_at` and `jobs.created_at`, whose
defaults `0006` had frozen at the instant it ran (rows created before `0019`
keep that frozen value — their real creation time is not recoverable).
- `0020` enforces NOT NULL on 20 timestamp columns that always had
`DEFAULT now()`. It aborts, changing nothing, if any NULL exists — clean
those rows explicitly first. `0021` adds the SSO federated identity (see "Human
access"); `0022` adds Agent releases + secure enrollment (see "Agent
distribution"). Current head: `0022_agent_releases_enrollment`
(also recorded in `release/RELEASE_MANIFEST.json`).
### HTTPS / reverse proxy
The Hub itself speaks plain HTTP. For a pilot reachable outside the Hub's
own host, put it behind a reverse proxy (nginx, Caddy, Traefik — whatever
the GTI/Proxmox environment already standardizes on) that terminates TLS and
forwards to `fiscal-hub:8000`. Nothing in the app assumes a particular
proxy; `BASE_URL` in `.env` is informational only (not currently used to
generate absolute links, but keep it accurate for anyone reading the config).
**GTI production (2026-09-23):** TLS terminates in the GTI Nginx Proxy Manager
(10.0.10.100), which forwards `http://<hub-lan-ip>:8000`. On the Hub host:
- `.env`: `HUB_BIND=<hub LAN IP>` (not `0.0.0.0`), `FORWARDED_ALLOW_IPS=<NPM IP>`
(real client IP and `https` scheme from `X-Forwarded-*`), `BASE_URL=https://…`,
and `WEB_COOKIE_SECURE=1` once HTTPS works. Redirects are relative, so the
internal address never leaks.
- Docker-published ports bypass UFW: install `restrict_hub_port.sh` +
`gti-fiscal-hub-port.service` (instructions in the unit file) so only the
NPM reaches the port, re-applied on every Docker start.
## Human access: SSO from GTI Gerenciador
Collaborators never type a second password. GTI Gerenciador (the panel) is the
identity authority; the Hub keeps its own sessions.
1. "Fiscal" in the panel sidebar -> panel `/sso/fiscal` -> Hub `GET /login`,
which sets a one-time state cookie (`gti_sso_state`, HttpOnly, Secure,
SameSite=None, 5 min) and redirects to `SSO_START_URL?state=...&next=...`.
2. The panel requires its own login (normal login page if needed), checks the
employee is active and auto-POSTs a JWT to `/auth/sso/callback`:
Ed25519 (`EdDSA` only), `iss=gti-gerenciador`, `aud=gti-fiscal`,
`sub=funcionario:<ID>`, 60 s, unique `jti`, `nonce`=state. Never in a URL.
3. The Hub verifies signature/iss/aud/exp/nbf/lifetime, the nonce against the
state cookie (login-CSRF protection), records the `jti`
(`sso_used_assertions`, replay = rejected), provisions or refreshes the
collaborator in `admin_users` (`auth_source=GTI_GERENCIADOR`, no password,
enforced by `ck_admin_users_auth_source`) and opens a session of
`FEDERATED_SESSION_SECONDS` (default 1 h; afterwards the same flow signs the
user in again silently, which also picks up a deactivation in the panel).
Keys: a dedicated Ed25519 pair, used for nothing else (never reuse
SESSION_SECRET, admin, Agent or DB secrets). The **private** key
(raw 32-byte seed, base64url) goes only to the panel's `.env`
(`SSO_FISCAL_CHAVE_PRIVADA`); the **public** key only to this Hub's `.env`
(`SSO_GERENCIADOR_CHAVE_PUBLICA`). Rotating = new pair in both, restart both.
Logout ("Sair do Fiscal") ends only the Hub session; coming back through the
panel signs in again without a password. There is no distributed single logout.
Audit (`audit_events`): `SSO_LOGIN_SUCCESS`, `SSO_LOGIN_REJECTED` (with a stable
reason: `EXPIRED`, `INVALID_SIGNATURE`, `WRONG_AUDIENCE`, `WRONG_ISSUER`,
`REPLAY`, `STATE_MISMATCH`, `DISABLED_IN_FISCAL`, ...), `LOGOUT`,
`LOGIN_LOCAL_SUCCESS/FAILURE` — with the real client IP, never the token.
**Break-glass:** the local admin (`create_admin.py`) still exists for
emergencies at `/login/local` (not linked from the UI; every use is audited).
Disable a collaborator only in the Hub: `admin_users.is_active = false`.
## Registering an Agent (per client installation)
Run once per Agent installation, from anywhere with access to Postgres —
easiest is inside the running Hub container:
```bash
docker exec <fiscal-hub-container> python central/scripts/register_agent.py \
--company "Client Company Name" --name "loja-01"
```
This prints `AGENT_ID`, a freshly generated `AGENT_TOKEN` (shown once — copy
it immediately), and `COMPANY_ID`. Only the token's SHA-256 hash is ever
stored in Postgres (same `token_hash` mechanism `/v1/classify` already used
— no new auth system). Each Agent gets its own token; one Agent's token
never authenticates another Agent's jobs (`agent_id` is checked on every
Job operation).
## Agent distribution: install from the panel (secure enrollment)
An alternative to `register_agent.py` for a real pilot: the panel drives the
whole installation, and the technician on the client's Windows host never
gets a permanent secret by hand.
**One-time: publish a release.** The Agent's universal package (one zip per
version — never one per client) is built and published by script, never
uploaded through a browser:
```bash
python scripts/build_agent_package.py # -> dist/GTIFiscalAgent-<version>.zip
docker exec <fiscal-hub-container> python central/scripts/publish_agent_release.py \
--zip dist/GTIFiscalAgent-<version>.zip --notes "..."
```
Publishing the same version again with different bytes is refused (bump the
version instead); publishing identical bytes again is a no-op. The zip lives
on the Hub's local `agent_releases` volume (`AGENT_RELEASE_STORAGE_PATH`,
default `/app/data/releases`), never in PostgreSQL — only its metadata
(`agent_releases`: version, sha256, size, status DRAFT/PUBLISHED/REVOKED) is
a row. `Agents / Servidores -> Releases` lists and can revoke a release.
**Per installation:** `Agents / Servidores -> + Nova instalação` — pick the
client, a display name, an optional expected hostname and the release,
"Criar instalação". That installation's page then offers:
- **Baixar Agent agora** — an authenticated, immediate download (the operator
is already logged in).
- **Gerar link temporário** — a one-time HTTPS URL
(`/dl/agent/<download-token>`), shown exactly once, TTL
`AGENT_DOWNLOAD_LINK_TTL_SECONDS` (default 30 min), single use
(`AGENT_DOWNLOAD_MAX_USES_DEFAULT`, default 1). Hand it to a technician or
open it directly on the client's server — no login required to use it.
Either path streams a bundle = the universal package + a freshly minted
`enrollment.json` (installation id, a one-time enrollment token, the Hub URL)
written at download time. Only that enrollment token's SHA-256 hash is ever
stored (`agent_enrollments.enrollment_token_hash`); its plaintext exists only
inside that one HTTP response — never on disk on the Hub, never logged.
**On the client Windows host**, elevated PowerShell, inside the extracted
package:
```powershell
.\gti-agent.ps1 install-auto
```
This checks every file in the bundle against `MANIFEST.json`'s SHA-256,
refuses a non-HTTPS Hub URL, calls `POST /v1/agents/enroll` with the
enrollment token (deleting `enrollment.json` immediately after, success or
failure), writes `.env` with the newly issued **permanent** `AGENT_TOKEN`
(ACL-restricted to Administrators/SYSTEM, same as `configure`), and
registers the Windows Service — **without starting it**: Firebird is still
unconfigured on purpose (this script never guesses a client's database path
or credentials, same rule as the manual path). Edit `FIREBIRD_DATABASE_PATH`
/ `FB_USER` / `FB_PASSWORD` / `COMPANY_CODE` in `.env`, then
`.\gti-agent.ps1 preflight ; .\gti-agent.ps1 start`. Running `install-auto`
again after a successful enrollment is a no-op (idempotent).
The Hub rejects: an expired or already-used download link, a revoked
installation or release, a wrong/tampered/replayed enrollment token, and a
second enrollment attempt for an already-enrolled installation. A hostname
that doesn't match what the operator expected is recorded
(`hostname_mismatch`) and audited, but does not block enrollment. Both
public routes (`GET /dl/agent/{token}`, `POST /v1/agents/enroll`) are
rate-limited per source IP. Audit actions: `INSTALLATION_CREATED`,
`DOWNLOAD_LINK_CREATED`, `DOWNLOAD_LINK_REJECTED`, `PACKAGE_DOWNLOADED`,
`ENROLLMENT_SUCCEEDED`, `ENROLLMENT_REJECTED`, `AGENT_CREDENTIAL_ISSUED`,
`INSTALLATION_REVOKED`, `AGENT_RELEASE_REVOKED` — the real IP, never a token.
Revoking a pending or enrolled installation (`Revogar` on its detail page)
invalidates any outstanding link/enrollment token immediately and, if an
Agent was already provisioned, disables it (`agents.enabled = false`) — its
existing sessions/jobs stop authenticating on the next request.
## Configuring an Agent installation
On the client machine, copy `agent/.env.example` to `agent/.env` and fill in
the values from registration plus that machine's Firebird details:
```
GTI_HUB_URL=https://hub.example.com
AGENT_ID=<from registration>
AGENT_TOKEN=<from registration>
COMPANY_ID=<from registration>
COMPANY_UF=BA
FIREBIRD_DATABASE_PATH=C:\ERP\dados\PRODUCAO.FDB
FB_USER=...
FB_PASSWORD=...
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=10
AGENT_VERSION=0.1.0
```
**`FIREBIRD_WRITES` must default to `0`.** See "Write safety" below before
ever setting it to `1`.
## Installing the Agent as a Windows Service
Requires `pywin32` (`pip install pywin32`, or `pip install -e ".[windows-service]"`
from `agent/`).
```powershell
# From an elevated (Administrator) terminal, in agent/service/:
python gti_agent_windows_service.py --startup=auto install
python gti_agent_windows_service.py start
# One-time, so Windows restarts it automatically after a crash:
sc.exe failure "GTIFiscalAgent" reset= 86400 actions= restart/60000/restart/60000/restart/60000
```
`gti_agent_windows_service.py` is a thin wrapper: it does not reimplement
polling, heartbeat, or the pipeline — it calls `run_agent_service.run()`
directly, stopping it via the Service Control Manager's own stop signal
instead of `SIGINT`/`SIGTERM` (which don't apply to services running with no
console). See the module's docstring for the exact commands.
### Install / configure / start / status / stop / restart / uninstall
```powershell
# Install (registers with SCM, Startup=Automatic)
python gti_agent_windows_service.py --startup=auto install
# Configure: edit agent/.env, no service restart needed until you `restart`
notepad agent\.env
# Start
python gti_agent_windows_service.py start
# Status
sc.exe query GTIFiscalAgent
# Stop
python gti_agent_windows_service.py stop
# Restart (picks up any agent/.env changes)
python gti_agent_windows_service.py restart
# Uninstall
python gti_agent_windows_service.py stop
python gti_agent_windows_service.py remove
```
### Updating the Agent's version
1. Stop the service: `python gti_agent_windows_service.py stop`
2. Replace the `agent/` code with the new version (git pull, or copy files).
3. Bump `AGENT_VERSION` in `agent/.env` if you track it per-release.
4. Start the service again: `python gti_agent_windows_service.py start`.
5. Confirm the new version shows up via `GET /v1/agents/me` on the Hub.
There is no auto-update mechanism yet (deliberately out of scope for this
pilot) — updates are a manual stop/replace/start.
### Running without installing as a service (dev/testing)
```bash
cd agent
python scripts/run_agent_service.py # runs forever, Ctrl+C to stop
python scripts/run_agent_service.py --max-iterations 5 # bounded, for scripting/tests
```
## Creating and checking work
See `docs/OPERATIONS.md` for the full `curl` examples
(`POST /v1/jobs` with `kind: REPROCESS_ALL` or `REPROCESS_PRODUCTS`,
`GET /v1/jobs/{id}` for status). Every Job is scoped to the Agent whose
token created it — no cross-Agent visibility.
## Checking Agent status
```bash
curl $GTI_HUB_URL/v1/agents/me -H "Authorization: Bearer $AGENT_TOKEN"
```
Returns `agent_id`, `company_id`, `enabled`, `last_seen_at`, `last_status`,
`schema_fingerprint`, `agent_version`, and a derived `status` of `ONLINE` or
`OFFLINE` (offline if no heartbeat arrived within
`AGENT_ONLINE_THRESHOLD_SECONDS`, default 120s — configurable on the Hub).
No dashboard yet; this is the query an operator or a future dashboard would
poll.
## Write safety: enabling FIREBIRD_WRITES for a real client
**Do not set `FIREBIRD_WRITES=1` against a real client database casually.**
The authorization model has two independent layers, both required:
1. **Path**: `AUTHORIZED_WRITE_DATABASE_PATH` in that installation's
`agent/.env` must be set to the *exact* absolute path of
`FIREBIRD_DATABASE_PATH` for that same installation. If it's unset, the
guard falls back to the developer-only disposable test database path
(`TESTESOFICIAIS.FDB`), so a real client path is refused automatically —
authorization is never inferred from the filename or the `.FDB`
extension.
2. **Schema fingerprint**: `EXPECTED_SCHEMA_FINGERPRINT` must equal the live
schema fingerprint that installation reports in its heartbeat
(`schema_fingerprint`, visible via `GET /v1/agents/me`). If the live
schema ever drifts from what was verified, writes are refused until an
operator re-verifies and updates this value.
Both checks happen before any write connection is opened
(`scripts/firebird_write_executor.py::assert_authorized_write_path` /
`assert_schema_fingerprint_authorized`), and before any product is even
read from Firebird for that job.
Even with both layers satisfied, auto-apply only ever touches
`C000025.IPI`, and only when the TIPI match is `rate_kind=NUMERIC` and
unambiguous. **No real client database has been written to during this or
any prior mission** — all writes so far were against `TESTESOFICIAIS.FDB`.
## Logs and diagnostics
- **Agent**: rotating local log file (`AGENT_LOG_PATH` in `agent/.env`,
default `agent/logs/agent.log`, 5MB × 5 backups). Contains startup,
heartbeat outcomes, job received/finished, products processed, write
counts, failures, and Hub reconnects — never secrets (`FB_PASSWORD` and
`AGENT_TOKEN` are never logged). Detailed per-product audit is in the
Agent's SQLite file (`AGENT_STATE_DB`, table `ipi_write_log`).
- **Hub**: structured `logging` output (visible via `docker compose logs
fiscal-hub`) for startup, health-check failures, heartbeats, and the full
Job lifecycle (created/claimed/completed/failed, with sanitized errors).
Coarse per-Job audit persists in Postgres (`jobs.result_summary`).
## Troubleshooting
| Symptom | Check |
|---|---|
| Agent shows `OFFLINE` | Is the service running (`sc.exe query GTIFiscalAgent`)? Check `agent/logs/agent.log` for heartbeat failures — usually `GTI_HUB_URL` unreachable or wrong `AGENT_TOKEN`. |
| Job stuck `PENDING` | Confirm the Agent that owns it is running and polling (its `agent_id` must match the Job's `agent_id` — an Agent never claims another Agent's Job). |
| Job `FAILED` | `GET /v1/jobs/{id}` for the sanitized `error`. Common causes: Firebird unreachable, `FIREBIRD_WRITES=1` misconfigured (see "Write safety"), Hub unreachable at report time (the Agent keeps the local `ipi_write_log` regardless). |
| `writes_attempted` always 0 with `FIREBIRD_WRITES=1` | Check both `AUTHORIZED_WRITE_DATABASE_PATH` and `EXPECTED_SCHEMA_FINGERPRINT` are set and correct for that installation. |
| Hub `/health` returns 503 | Postgres is down or unreachable; check `docker compose ps` and `docker compose logs postgres`. |
## Rollback / uninstalling an Agent
1. Stop and remove the Windows service (see above).
2. Delete the `agent/` installation directory on that machine (its SQLite
audit and logs are local-only; nothing to clean up centrally).
3. On the Hub, disable the Agent rather than deleting history: set
`enabled = false` for that `agents` row (no HTTP endpoint for this yet —
run it via `psql`/a short script against `DATABASE_URL`, the same access
`register_agent.py` uses). Its past Jobs and `classification_cache`
entries remain for audit.
## Rolling back the Hub
`docker compose down` does not remove the `postgres_data` volume — data
survives. To roll back to a previous Hub image/version, redeploy that
version's code and run `alembic upgrade head` again (migrations are
additive, so this is safe going forward; there is no tested downgrade path
for `0006`, so don't `alembic downgrade` in a pilot without restoring from a
backup first).
## Zero-touch Agent (Setup 1.1.3+): install once, control from the panel
The operator runs `GTIFiscalAgent-Setup-<version>.exe` **once** and types the install
code. Nothing else happens on the client server — no PowerShell, no `agent.env` editing,
no `services.msc`, no per-client cron:
1. **Firebird discovery (read-only).** Setup finds the Firebird service and candidate
`.FDB` files and probes each one (read-only connection) for `C000025` + `C000004`.
Exactly one SGV database -> selected automatically. Several, none, or credentials
other than the SGV default -> the wizard asks *during the installation* (list / file
picker + user/password, verified before use). It never guesses among several.
2. **Enrollment** with the install code, then three protected files (DPAPI LocalMachine +
SYSTEM/Administrators ACL): `credentials.bin` (Hub token), `firebird.bin` (Firebird
user/password, **never sent to the Hub**), and a non-secret `agent.env`.
3. **Service** `GTIFiscalAgent`: Automatic Delayed Start, restart-on-failure, **started by
Setup**; it waits for the first heartbeat and tells the operator "nenhuma outra ação".
4. **First sync is automatic.** On the first heartbeat of an installation without
coverage the Hub queues exactly one `INITIAL_SYNC` (idempotent). Afterwards the Hub's
scheduler queues one `SYNC_INCREMENTAL` per interval (default 30 min,
`installations.sync_interval_seconds`), only when nothing is queued/running.
`SYNC_INCREMENTAL` re-classifies only new/changed products (per-product source
fingerprint cache in the Agent's SQLite) and rebuilds coverage from the cache.
`REPROCESS_ALL` runs only when an operator asks for it.
5. **Central control** (Agent detail page, no shell, no arbitrary commands):
*Sincronizar agora*, *Reprocessar tudo*, *Pausar / Retomar processamento*, plus status
(Ativo/Pausado, última/agendada sincronização, produtos alterados, último job).
Pause = the heartbeat keeps flowing but the Hub hands out no job and schedules none.
The old local `AGENT_JOBS_ENABLED` is ignored — there is no local toggle.
Guarantees: one fiscal job per Agent at a time (FIFO queue, a job stuck RUNNING > 1 h is
failed so it cannot block the queue), a failed sync backs off for the interval instead of
looping, a Hub or Firebird outage fails the job cleanly and the service keeps running.
Every job kind is **read-only on the ERP** (`FIREBIRD_WRITES=0`, `FISCAL_MODE=DRY_RUN`).
Upgrading an installed host: run the newer Setup again (no code needed). It replaces the
binaries, keeps `agent_id`/`installation_id`/credentials, removes the legacy local toggle and
restarts the service.