Deploy e ambiente
Executando localmente (desenvolvimento)
Pré-requisitos
- Java 21+
- Maven 3.9+
- Node.js 20+ e npm
- PostgreSQL 16 com a extensão
pgvector, local ou via Docker.docker-compose.ymlusa a imagempgvector/pgvector:pg16; um PostgreSQL 16 sem a extensão falha na criação dovector_storeno start
1. Subir o banco de dados
docker compose up -d dbIsso inicia apenas o container do PostgreSQL na porta 5432.
2. Executar o backend
cd backend
export DB_HOST=localhost
export DB_PORT=5432
export DB_NAME=flow-iagentic
export DB_USERNAME=flow-iagentic
export DB_PASSWORD=flow-iagentic
export JWT_SECRET=dev-secret-key-min-256-bits-for-hmac-sha256-algorithm
mvn install -DskipTests
mvn spring-boot:run -pl core -Dspring-boot.run.profiles=dev -Dcheckstyle.skip=trueO backend estará disponível em http://localhost:8080.
Duas armadilhas deste passo — mvn package no lugar de mvn install, e o container que não recarrega sozinho — estão em Diagnóstico do ambiente local, ao fim desta seção.
O terceiro artefato: o agente de runner
O mesmo mvn install também gera backend/runner-agent/target/flowia-runner-agent.jar. Ele não é a aplicação nem o RobotRunner.jar: é o agente de longa duração que se instala na máquina do cliente, busca trabalho na plataforma e sobe o sandbox por lá.
O agente não pode depender do core
runner-agent roda numa máquina que a plataforma não administra. Uma dependência para o core levaria junto o roteamento de datasource por tenant, o engine Flowable e o driver do banco para dentro da rede do cliente — e compilaria sem quebrar nada. AgentCarriesNoCoreTest lê o classpath resolvido e falha se isso acontecer; é ele que faz a violação aparecer no build em vez de aparecer num servidor de cliente.
O ciclo fecha de ponta a ponta: a plataforma atribui, o agente busca em /api/internal/runners/work, baixa os dois JARs conferindo o SHA-256, sobe o filho e devolve o desfecho. Os detalhes estão em specs/automation/robot-runners.md.
Esse mesmo build também monta backend/robot-runner/target/RobotRunnerPython.zip (o bootstrap e o SDK flowia_robot dos robôs Python), e a imagem do backend o copia para /app/runner/ ao lado do RobotRunner.jar. A plataforma o serve em GET /api/internal/runners/artifacts/runner?runtime=PYTHON, com o SHA-256 no cabeçalho, de app.robots.python-runner-path (padrão runner/RobotRunnerPython.zip).
O agente aceita robôs Python quando acha um Python 3.10+ com venv e pip:
| Opção | Efeito |
|---|---|
--python <caminho> | Interpretador a usar. Sem ela, python3 no PATH (no Windows, python3 e depois python). Sem Python 3.10+ o agente sobe do mesmo jeito, só não declara Python e não recebe robô Python |
--python-env NOME=VALOR | Variável de ambiente que o operador libera aos robôs Python (repetível), por exemplo PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright. O robô nunca define variáveis; PATH, HOME, temporários, BASH_ENV, ENV e as famílias PYTHON*, LD_*, DYLD_* e PIP_* são recusados na partida. Os valores não vão para o log do agente |
--python-max-memory-mb <n> | RLIMIT_AS de cada robô Python, padrão 2048. Só vale em Linux; no macOS e no Windows é um aviso no log da execução, não um limite. Um robô com navegador precisa de mais, porque o Chromium reserva muito mais memória virtual do que usa |
Antes de subir o agente é preciso cadastrar o runner na tela, colando a chave pública que o --init imprime. Os comandos e o passo a passo da tela estão em Robôs → Cadastrando um runner.
3. Executar o frontend
cd frontend
npm install
npm run devO frontend estará disponível em http://localhost:3000 (vite.config.ts → server.port). Servir em outra porta exige acrescentá-la a app.cors.allowed-origins, senão toda chamada responde Invalid CORS request.
Diagnóstico do ambiente local
install não é opcional, e package não serve
O reator tem três módulos: core é a aplicação, robot-runner é o sandbox onde os robôs rodam e runner-agent é o agente instalado na máquina do cliente. core depende de robot-runner, e resolve essa dependência pelo repositório local ~/.m2 — não pela pasta target/ do irmão. runner-agent não depende de nenhum dos dois, de propósito.
mvn package compila os dois e não publica nada em ~/.m2, então o cache continua com a versão anterior do robot-runner. mvn install é o que atualiza. A falha não aparece no build: aparece no próximo boot, como erro de compilação dentro do container, apontando para código que você jurava ter corrigido.
Se você usa o ambiente em Docker, o ~/.m2 do host é montado dentro do container (docker-compose.dev.yml), então o cache é o mesmo — e o problema também.
O mesmo install deixa o RobotRunner.jar em backend/core/runner/, a pasta de onde o backend local tira o sandbox. O jar não é versionado: num clone novo, nenhum robô roda antes do primeiro mvn install.
O reinício do container é pedido por você, não pelo compilador
docker-compose.dev.yml monta ./backend/core/target dentro do container, e esse mount é o mecanismo do hot reload: você compila no host e o container enxerga os .class novos na hora.
Ele não recarrega sozinho. Quando o build terminar, peça o reinício:
touch backend/core/target/classes/.reloadtriggerO DevTools então aplica de uma vez tudo que mudou desde o último reinício (~1-2s, sem reiniciar a JVM). Vale igual para um build feito dentro do container (docker compose exec app mvn compile -q -pl core).
O que segura o reinício automático é spring.devtools.restart.trigger-file: .reloadtrigger, em application-dev.yml — só o profile dev, nunca prod nem staging. SPRING_DEVTOOLS_RESTART_ENABLED=false não serviria: spring-boot:run instala o RestartInitializer e o DevTools reinicia mesmo assim, dizendo isso no log. O trigger-file, ao contrário, é lido depois, pelo autoconfigure — o watcher continua de pé e só deixa de tratar um .class novo como motivo para reiniciar.
Sem o trigger-file, qualquer build reinicia o container — inclusive um mvn test de uma classe só, e inclusive no meio da compilação, com as classes ainda pela metade. Os sintomas não se parecem com a causa: NoClassDefFoundError, uma @Configuration que nunca registra, um preflight recusando o que deveria aceitar.
Se você tocar o arquivo e nada acontecer, confira que ele está em target/classes (um mvn clean apaga; o touch recria) e que o log do container mostra o Restarting due to ....
Variáveis de ambiente (Backend)
| Variável | Descrição | Padrão (dev) |
|---|---|---|
DB_HOST | Host do PostgreSQL | localhost |
DB_PORT | Porta do PostgreSQL | 5432 |
DB_NAME | Nome do banco | flow-iagentic |
DB_USERNAME | Usuário do banco | flow-iagentic |
DB_PASSWORD | Senha do banco | flow-iagentic |
JWT_SECRET | Chave secreta para assinar JWTs | — |
APP_ENCRYPTION_KEY | Cifra credencial de terceiro em repouso: chave do provedor de IA, segredo de assinatura dos webhooks, variável de tenant secreta, o client secret do SSO e as configurações de e-mail. Perdê-la torna tudo isso ilegível, e ela não pode ser rotacionada depois que há segredo gravado | — |
SECRETS_INJECT_INTO_PROCESS_VARIABLES | Com false — recomendado — variável secreta nunca chega à instância, então as tabelas de runtime e de histórico do Flowable não guardam credencial. A expressão passa a usar secrets.get(execution, 'chave') | true |
AI_HTTP_TOOL_DEFAULT_POLICY | Com deny, agente sem lista de destinos não chama fora — recomendado | allow |
AI_STEP_CORE_SIZE | A capacidade de IA da instalação: etapas de IA simultâneas em regime, na pool dedicada em que a etapa espera o provedor. É este número que se cita | 8 |
AI_STEP_QUEUE_CAPACITY | Etapas de IA que esperam vaga antes de a pool recusar. Recusada, a etapa é encerrada como falha e fica disponível para reprocesso | 100 |
AI_STEP_PROVIDER_TIMEOUT_SECONDS | Quanto a etapa espera o provedor antes de desistir. O lease da reserva é o dobro disso | 120 |
AI_RATE_LIMIT_REQUESTS_PER_MINUTE | Requisições por minuto que cada nó manda a um mesmo modelo (modelo + chave do provedor), contando cada rodada de ferramenta. 0 = sem teto de taxa. Ponha o RPM do seu tier dividido pelo número de nós | 0 |
AI_RATE_LIMIT_MAX_CONCURRENT_CALLS | Chamadas simultâneas de etapa de IA a um mesmo modelo, por nó. 0 = sem teto além da pool. Etapa que não acha vaga volta para a fila na hora, sem prender a thread — um modelo no teto não atrasa etapas de outros modelos | 0 |
AI_RATE_LIMIT_MAX_WAIT_SECONDS | Quanto uma etapa segura a thread esperando a vez na taxa por minuto antes de voltar para a fila. Mantenha bem abaixo do lease | 30 |
AI_RATE_LIMIT_MAX_RETRY_DELAY_SECONDS | Teto da espera antes de nova tentativa — a dica do provedor num 429 de taxa, ou o backoff de um 5xx. Também é o teto da pausa do modelo depois de um 429: um Retry-After de uma hora pausa só até aqui | 120 |
AI_RATE_LIMIT_MAX_HOLD_MINUTES | Quanto tempo, no total, uma etapa pode ficar esperando capacidade sem chamar o provedor antes de virar falha | 30 |
AI_RATE_LIMIT_QUOTA_EXHAUSTED_PAUSE | Por quanto tempo um modelo fica marcado como sem cota neste nó depois de um 429 de cota do provedor, quando ele não diz quando volta. Enquanto isso, agentes de classe vão direto ao modelo seguinte | 60m |
FLOWABLE_EXECUTOR_CORE_SIZE | Jobs do Flowable simultâneos em regime — timers, continuações assíncronas e a conclusão da etapa de IA. Desde 2026-09-09 não é o teto da IA | 2 |
FLOWABLE_EXECUTOR_MAX_SIZE | Teto do pool de jobs. A thread extra só nasce com a fila de 100 cheia, e nesse ponto o job já está sendo rejeitado — não é capacidade | 3 |
JWT_EXPIRATION_MS | Expiração do access token (ms) | 3600000 (1h) |
SPRING_PROFILES_ACTIVE | Perfil Spring ativo | dev |
APP_NODE_ROLE | all, api ou worker. Decide se o nó adquire job do motor, roda tarefa agendada de cluster e aplica o Flyway compartilhado. Ver "Papéis de nó" | all |
APP_NODE_AFFINITY_ENABLED | Com true, cada tenant tem um nó dono e o nó só adquire job dos tenants que possui. Ver "Afinidade de tenant" | false |
APP_NODE_AFFINITY_HTTP | Declara que o roteador na frente distribui por X-Tenant-ID. Não muda o roteamento: só a conta de conexões, que a aplicação não tem como observar sozinha | false |
APP_DATABASE_MAX_CONNECTIONS | max_connections do servidor de banco. 0 significa "não informado" e desliga a comparação | 0 |
APP_DATABASE_EXPECTED_TENANTS | Quantos tenants ativos a instalação espera. Entra na conta do teto de conexões | 0 |
APP_DATABASE_EXPECTED_PODS | Quantos pods de backend a instalação espera | 1 |
APP_DATABASE_BUDGET_ENFORCE | Com true, o nó recusa subir quando a conta passa de APP_DATABASE_MAX_CONNECTIONS, em vez de só avisar | false |
APP_DATABASE_ROLE_ENFORCE | Com true, o nó recusa subir quando a role de banco é rolsuper ou rolbypassrls, em vez de só avisar. Ver "A role de banco" | false |
AI_FAIL_ON_EMPTY_RESPONSE | Falha a tarefa quando o agente devolve resposta vazia, em vez de gravar um valor em branco | true |
AI_LOG_RAW_RESPONSE_ON_EMPTY | Loga a resposta bruta do provedor quando ela chega sem nenhum candidato, para diagnosticar o motivo do bloqueio | true |
AI_TASK_FAILURE_RETENTION_DAYS | Dias que um registro de falha de agente fica guardado antes da limpeza diária | 90 |
FORM_PASSWORD_HISTORY_RETENTION_DAYS | Dias que a senha digitada num campo de formulário fica no histórico, já cifrada, antes da varredura noturna apagá-la | 7 |
FLOWABLE_HISTORY_RETENTION_DAYS | Dias que o histórico (BPMN e CMMN) de uma instância encerrada fica no banco antes da poda noturna. Instância aberta nunca é tocada. Ver "Retenção de dados" | 365 |
FLOWABLE_HISTORY_RETENTION_ENABLED | Com true, a poda noturna apaga o histórico do Flowable das instâncias encerradas há mais de FLOWABLE_HISTORY_RETENTION_DAYS. Desligado, nada é apagado | false |
AI_STEP_RESERVATION_RETENTION_ENABLED | Com true, apaga as reservas de etapa de IA terminadas há mais de AI_STEP_RESERVATION_RETENTION_DAYS | false |
ROBOT_EXECUTION_RETENTION_ENABLED | Com true, apaga as execuções de robô terminadas há mais de ROBOT_EXECUTION_RETENTION_DAYS, com o log | false |
FLOWABLE_HISTORY_LEVEL | Nível de histórico dos dois motores: none, activity, audit ou full. activity tira o histórico de variáveis que a mineração de campos e a tela da instância encerrada leem | audit |
AI_STEP_RESERVATION_RETENTION_DAYS | Dias que uma reserva de etapa de IA terminada (COMPLETED/FAILED) fica guardada. A de instância que ainda roda fica sempre | 30 |
ROBOT_EXECUTION_RETENTION_DAYS | Dias que uma execução de robô terminada fica guardada, com o log. A última execução de cada robô fica sempre | 180 |
RETENTION_ALLOW_TENANT_CHOICES | Com false, a escolha de retenção feita pelo Admin de cada tenant é ignorada e vale só a janela da instalação: nenhum tenant apaga nada com o padrão desligado. A tela de retenção avisa e trava as opções. Helm: app.retention.allowTenantChoices | true |
ROBOT_EXECUTION_RETENTION_DELETE_LOG_OBJECTS | Com false, a execução sai do banco e o log selado fica no bucket, entregue à regra de ciclo de vida dele | true |
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE | O único pool do nó, e por isso o orçamento inteiro dele: (pool + 1) × pods. Tem piso — ver "O piso do pool, e por que ele não desce sozinho" | 10 |
TENANT_POOL_MAX_SIZE | Teto de conexões que um tenant pode segurar dentro do pool compartilhado. Não é um pool e não abre conexão nenhuma: é o anteparo que impede uma empresa de consumir a conexão das outras. O padrão vem do papel do nó: 7 em all/worker, 3 em api. Tem o mesmo piso | 7 |
APP_STORAGE_TYPE | S3 ou LOCAL. Fora dos perfis dev e test a aplicação recusa subir com LOCAL | LOCAL (só serve em dev) |
APP_STORAGE_S3_ENDPOINT | Endpoint compatível com S3. Vazio significa a própria AWS S3 | — |
APP_STORAGE_S3_REGION | Região do bucket | us-east-1 |
APP_STORAGE_S3_BUCKET | Bucket onde ficam anexos, fotos de perfil e JARs de robô. Obrigatório fora de dev/test | flow-iagentic |
APP_STORAGE_S3_ACCESS_KEY | Chave de acesso. Obrigatória fora de dev/test | — |
APP_STORAGE_S3_SECRET_KEY | Chave secreta. Obrigatória fora de dev/test | — |
RAG_INGESTION_MAX_FILE_SIZE | Tamanho máximo de um documento de base de conhecimento. Vale só para a rota de upload da base, que lê o próprio multipart com esse limite (com ou sem Content-Length); os outros uploads continuam em UPLOAD_MAX_FILE_SIZE. O proxy na frente precisa aceitar o mesmo tamanho | 50MB |
UPLOAD_MAX_FILE_SIZE | Limite de multipart do container, para todos os outros uploads (anexos, foto de perfil). Acima dele, 413. Não vale para o envio do artefato de robô, que lê o próprio corpo | 10MB |
ROBOTS_MAX_ARTIFACT_SIZE_MB | Teto do envio do artefato de robô (JAR ou pacote Python), em MB, lido em fluxo para um arquivo temporário. Vale só para POST /api/a/robots/{key}/artifact e /jar. O proxy precisa aceitar o mesmo tamanho: deploy/nginx.prod.conf já traz a rota a 201 MB; no Helm, ponha nginx.ingress.kubernetes.io/proxy-body-size em ingress.annotations | 200 |
ROBOTS_MAX_CONCURRENT_UPLOADS, ROBOTS_UPLOAD_TEMP_DIR | Quantos envios de artefato de robô uma instância atende ao mesmo tempo (acima disso, 429) e a pasta onde o corpo é guardado enquanto é conferido (vazio: a pasta temporária do sistema). Cada envio pode ocupar até ROBOTS_MAX_ARTIFACT_SIZE_MB em disco | 4, vazio |
ROBOTS_PYTHON_ENABLED | Aceita pacotes Python no envio do robô. Deixe desligado até os runners terem Python 3.10+ | false |
ROBOTS_PYTHON_RUNNER_PATH (app.robots.python-runner-path) | Onde o backend lê o RobotRunnerPython.zip que serve aos runners e que a tela de robôs oferece em Baixar SDK Python. A imagem já o copia para /app/runner/; numa instalação montada à mão, aponte para backend/robot-runner/target/RobotRunnerPython.zip | runner/RobotRunnerPython.zip |
ROBOTS_PYTHON_MAX_ENTRIES, ROBOTS_PYTHON_MAX_UNCOMPRESSED_MB, ROBOTS_PYTHON_MAX_COMPRESSION_RATIO | Limites da conferência do pacote contra bomba de descompressão: entradas, MB descompactados e razão de compressão por arquivo | 20000, 1024, 100 |
RAG_INGESTION_MAX_PAGES | Páginas de PDF, slides ou quadros de TIFF por documento; acima disso a versão falha com TOO_LARGE antes de qualquer embedding | 2000 |
RAG_INGESTION_MAX_ROWS | Linhas somando todas as planilhas de um documento | 200000 |
RAG_INGESTION_MAX_EXTRACTED_CHARS | Caracteres extraídos por documento. Também protege contra arquivo compactado que explode ao abrir | 5000000 |
RAG_INGESTION_TIMEOUT | Tempo máximo de extração por documento | 15m |
RAG_INGESTION_SHEET_ROWS_WARNING | Planilha com mais linhas que isto recebe o aviso de "tabela de código — use o CMS" | 5000 |
RAG_INGESTION_THREADS | Ingestões simultâneas por nó (pool rag-ingest-). Versão que não acha vaga fica PENDING e a varredura de 5 minutos a devolve à fila | 2 |
RAG_OCR_ENABLED | Vazio: OCR ligado só se o binário tesseract responder no nó. false desliga mesmo com o binário presente; true com o binário ausente continua desligado, com aviso no log | vazio |
RAG_OCR_LANGUAGES | Idiomas do Tesseract, separados por +. A imagem padrão traz por, eng e spa (tessdata_fast) | por+eng+spa |
RAG_OCR_MAX_PAGES_PER_DOCUMENT | Páginas que passam por OCR num documento; o resto fica de fora e a versão fica PARTIAL | 200 |
RAG_OCR_PAGE_TIMEOUT_SECONDS | Prazo por página; estourado, o processo do Tesseract é morto | 60 |
RAG_OCR_CONCURRENCY | Processos de OCR simultâneos por nó | 1 |
Deploy Enterprise (On-Premise / Self-Hosted)
O Flowi Agentic foi desenhado para rodar dentro da infraestrutura cloud privada do próprio cliente (AWS VPC, Azure, VM Local). Devido ao modelo BYOK (Bring Your Own Key), a instância roda isolada e não envia dados de telemetria ou billing para a Flowi Agentic Inc. Apenas a chave serial on-premise é configurada para destravar a licença.
# Build e start do cluster (Node padrão)
docker compose up --build -dPara cluster Kubernetes do cliente, use o Helm chart da seção seguinte em vez do Docker Compose.
Uma segunda réplica funciona, e agora tem papel próprio
O trabalho agendado não duplica: CRONs de robô, e-mails e limpezas passam por um lease em banco antes de executar, então só um nó roda cada um.
Os três obstáculos que falhavam em silêncio saíram:
- Tempo real chega aos dois pods. O que um nó publica passa por
LISTEN/NOTIFYdo Postgres e é entregue às sessões STOMP de todos. Não há broker novo para instalar e não é preciso afinidade de sessão. - Java Delegate enviado a um pod aparece nos outros dentro de
app.delegates.reconcile-interval-ms(60 s por padrão), sem reiniciar ninguém. - Limites de taxa são da instalação. O limitador de login e o orçamento de cada chave de API contam numa tabela compartilhada, então o número na tela é o número que vale com um pod ou com cinco.
O Flyway também saiu do boot: cada schema de tenant migra na primeira requisição dele e uma varredura com lease cuida do resto, então o tempo de boot não cresce mais com a quantidade de tenants.
Os runners escalam por conta própria, e por outro botão. runner.replicaCount é um StatefulSet: cada réplica é um runner distinto, com par de chaves próprio no próprio volume. Como um runner compartilhado roda uma execução por vez, é esse número — não backend.replicaCount — que decide quantos robôs correm ao mesmo tempo.
O segundo pod agora pode ter papel. APP_NODE_ROLE=api serve HTTP sem adquirir job, sem rodar tarefa agendada de cluster e sem aplicar o Flyway compartilhado, com teto por tenant menor; APP_NODE_ROLE=worker faz o inverso e responde só saúde no HTTP. Sem a variável o nó é all, que é o comportamento de sempre. Ver "Papéis de nó".
O que decide a contagem de pods é o orçamento de conexões, e ele multiplica só por pod: (sharedPool + 1) x pods — o parêntese é o custo de um pod, e o +1 é a conexão que ele mantém aberta para escutar os eventos. Desde 2026-09-09 não há parcela por tenant nessa conta. A aplicação faz a conta e a imprime em toda subida; informe APP_DATABASE_MAX_CONNECTIONS e APP_DATABASE_EXPECTED_PODS e ela avisa quando o número não cabe (APP_DATABASE_EXPECTED_TENANTS continua saindo na linha só para mostrar que não a move). Ver "Orçamento de conexões".
O volume ReadWriteOnce continua sendo o obstáculo que o chart detecta e recusa. Com S3 obrigatório ele é só cache: persistence.enabled=false tira esse do caminho.
Serviços no Docker
| Serviço | Container | Porta |
|---|---|---|
| Banco de dados | flow-iagentic-db | 5432 |
| Backend | flow-iagentic-app | 8080 |
Frontend
O frontend não roda em Docker por padrão — execute localmente com npm run dev.
O docker-compose não sobe runner; o chart sobe
O docker-compose não sobe nem cadastra runner nenhum: aqui, robô só executa depois que alguém cadastra um runner na tela e sobe o agente na máquina dele — ver Cadastrar um runner é parte de subir a instalação.
No Helm é diferente desde 2026-08-29. O chart sobe runner.replicaCount runners (1 por padrão), cada um gerando o próprio par de chaves dentro do próprio volume e se inscrevendo sozinho como GLOBAL. A chave privada nasce no pod que assina com ela e não viaja. Runner de um tenant específico continua exigindo alguém colando a chave pública na tela.
Deploy em Kubernetes (Helm)
O chart fica em helm/ e instala backend + SPA + runners no cluster do cliente. Ele não baixa nenhuma dependência: não há subchart remoto, então a instalação funciona em cluster sem saída para a internet, bastando as imagens estarem acessíveis.
Requisitos
| Kubernetes | 1.23+ |
| Helm | 3.8+ |
| Ingress controller | qualquer um — ou nenhum, com ingress.enabled=false |
| PostgreSQL | 16 com pgvector, embutido ou externo |
| Object storage | obrigatório — qualquer endpoint compatível com S3, mais um bucket e uma chave que leia, escreva e apague nele |
Avaliação — banco embutido, bucket seu
cd helm
helm install flowia ./flowia -n flowia --create-namespace --wait --timeout 20m \
--set storage.s3.endpoint=https://s3.empresa.com.br \
--set storage.s3.bucket=flowia-anexos \
--set storage.s3.accessKey=... --set storage.s3.secretKey=...
kubectl -n flowia port-forward svc/flowia-frontend 8080:80Sobe com PostgreSQL embutido e segredos gerados. As quatro flags de storage não são opcionais: object storage é requisito, e uma instalação sem elas falha na renderização em vez de falhar quando alguém anexar um arquivo. O primeiro boot roda o Flyway no schema compartilhado e em cada schema de tenant — leva minutos, e a startupProbe espera até 600s antes de declarar o pod morto. Não encurte esse tempo achando que travou.
O port-forward não entrega tempo real
As atualizações ao vivo da caixa de tarefas usam WebSocket em /ws, e quem roteia /ws é o Ingress — não o nginx do frontend. Sem Ingress, a tela funciona mas não atualiza sozinha.
Produção
helm install flowia ./flowia -n flowia --create-namespace \
-f examples/production.yaml --wait --timeout 20mhelm/examples/production.yaml é o formato, não os valores: banco do cliente, ingress do cliente, segredos gerenciados pelo cliente.
O banco é uma chave liga/desliga
# embutido (padrão) — StatefulSet pgvector + PVC, para avaliação
--set postgresql.enabled=true
# externo — o banco que o cliente já opera
--set postgresql.enabled=false \
--set externalDatabase.host=pg.empresa.com.br \
--set externalDatabase.database=flowia \
--set externalDatabase.username=flowia \
--set externalDatabase.existingSecret=flowia-dbO banco embutido não tem backup, failover nem pooler — existe para a avaliação instalar em um comando. O banco externo precisa permitir CREATE EXTENSION vector (o Spring AI cria a extensão no boot) e permitir que a aplicação crie schemas, porque cada tenant é um schema.
Se faltar externalDatabase.host, o helm template falha na hora com a mensagem escrita — não vira CrashLoopBackOff meia hora depois.
Object storage é requisito, não opção
Toda instalação exige S3. storage.type já vem S3, e storage.s3.endpoint e storage.s3.bucket não têm padrão: se faltar qualquer um dos dois, o helm template falha na hora com a mensagem escrita — do mesmo jeito que falha sem externalDatabase.host. Um helm install sem valor nenhum não é válido.
--set storage.s3.endpoint=https://s3.empresa.com.br \
--set storage.s3.bucket=flowia-anexos \
--set storage.s3.accessKey=... --set storage.s3.secretKey=...
# ou: --set secrets.existingSecret=flowia-secrets, com S3_ACCESS_KEY e S3_SECRET_KEY dentrostorage.type=LOCAL existe apenas para desenvolvimento e teste. Ali o volume em /data é o armazenamento dos anexos: um disco ReadWriteOnce, sem backup, sem réplica, sem política de ciclo de vida e sem segundo leitor — perder o volume é perder todos os anexos da instalação. A aplicação recusa subir com LOCAL fora dos perfis Spring dev e test, então um chart que renderize LOCAL não vai produzir um pod de pé.
storage.type | O volume em /data | persistence.enabled=false |
|---|---|---|
S3 (padrão) | é só cache dos JARs de robô | permitido — vira emptyDir |
LOCAL (dev/teste) | é o armazenamento dos anexos | recusado pelo chart |
O JAR do robô é gravado no storage durável no upload e baixado sob demanda quando some do disco, por isso o cache pode ser efêmero. persistence.enabled vem true por padrão para que um pod novo não precise buscar todos os JARs no bucket antes da primeira execução; com S3, desligá-lo custa essa latência e nada mais.
O chart não sobe MinIO. Aponte storage.s3.endpoint para o object storage que o cliente já tem — inclusive um MinIO que ele mesmo opere, o que é licença e decisão dele. Deixar o endpoint vazio é recusado em vez de virar "AWS S3": uma instalação on-premise que subisse os anexos da empresa para a Amazon calada é pior do que uma que não sobe.
Retenção de arquivo é regra do bucket, não job nosso
Expurgar anexo antigo não é trabalho da plataforma: ela não tem job de limpeza de arquivo e não vai passar a ter. Expirar objeto velho é uma lifecycle rule do bucket (ou o ILM do appliance), configurada por quem é dono do bucket, ao lado da política de retenção e de guarda legal que a empresa já tem.
Apagar um anexo pelo produto apaga o objeto na hora — isso é exclusão sob demanda, não retenção. O mesmo vale para o log selado de uma execução de robô que a retenção de dados apaga (abaixo): o objeto sai com a linha que aponta para ele, e mais nada. Se você escrever uma regra por idade, limite o escopo para que ela não expire objetos que o banco ainda referencia, senão o anexo volta para o usuário como erro de download.
Retenção de dados: o que a plataforma apaga sozinha
Três coisas crescem com o uso e, até aqui, nada as apagava: o histórico do Flowable (as tabelas act_hi_* e act_cmmn_hi_*, em public, compartilhadas por todos os tenants), as reservas de etapa de IA e as execuções de robô. Cada uma tem agora uma janela padrão para a instalação inteira e um job noturno que poda o que passou dela. Os três jobs vêm desligados: nada é apagado até o operador ligar cada um (*_RETENTION_ENABLED=true ou enabled: true no Helm), porque a deleção não tem volta e não arquiva nada antes:
| O quê | Janela padrão | Sai | Nunca sai |
|---|---|---|---|
| Histórico do Flowable (BPMN e CMMN) | 365 dias depois do fim da instância | a instância encerrada, com atividades, tarefas, variáveis e vínculos | instância aberta, suspensa ou parada num job morto — ela não tem data de fim |
| Reservas de etapa de IA | 30 dias depois de virar COMPLETED ou FAILED | a reserva terminada | reserva RESERVED/COMPLETING, e qualquer reserva de instância que ainda roda |
| Execuções de robô | 180 dias depois do fim da execução | a execução SUCCESS, FAILED, TIMEOUT ou CANCELLED, com as linhas de log e o log selado no bucket | execução em andamento ou pendente, a última execução de cada robô, e a execução cujo log o bucket se recusou a apagar (tenta de novo na noite seguinte) |
Os três jobs rodam entre 04:45 e 05:15, depois das demais limpezas e da mineração das 04:00, disputam o lease em job_leases — só um nó poda por noite, e um nó api nunca —, passam por todos os tenants com o filtro de tenant em cada comando e apagam em lotes. Cada passada registra no log quantas linhas saíram por tenant e soma no contador flowi.retention.removed (tag table).
No Helm, os valores ficam em app.retention:
app:
retention:
flowableHistory: { enabled: false, retentionDays: 365, minRetentionDays: 90, maxRetentionDays: , batchSize: 500, maxBatchesPerRun: 200, historyLevel: audit }
aiStepReservations: { enabled: false, retentionDays: 30, minRetentionDays: 7, maxRetentionDays: , batchSize: 1000, maxBatchesPerRun: 100 }
robotExecutions: { enabled: false, retentionDays: 180, minRetentionDays: 30, maxRetentionDays: , batchSize: 500, maxBatchesPerRun: 100, deleteLogObjects: true }Janela por tenant, dentro dos limites da instalação
O Admin de cada tenant escolhe a própria janela em Administração → Retenção de dados (ver a tela): padrão da instalação, desligado, ou N dias. O que o operador define aqui é o padrão — o que vale para quem não escolheu nada — e os limites:
| Valor (Helm / variável) | O que faz | Padrão |
|---|---|---|
minRetentionDays / FLOWABLE_HISTORY_RETENTION_MIN_DAYS, AI_STEP_RESERVATION_RETENTION_MIN_DAYS, ROBOT_EXECUTION_RETENTION_MIN_DAYS | Menor janela que um tenant pode escolher. Protege o que a instalação precisa guardar | 90 / 7 / 30 |
maxRetentionDays / FLOWABLE_HISTORY_RETENTION_MAX_DAYS, AI_STEP_RESERVATION_RETENTION_MAX_DAYS, ROBOT_EXECUTION_RETENTION_MAX_DAYS | Maior janela que um tenant pode escolher. Vazio, sem máximo. Com máximo, "desligado" deixa de ser opção para o tenant | vazio |
enabled: falsedesliga o padrão, não a tela: um tenant cujo Admin escolheu N dias é podado com a janela dele mesmo com o padrão desligado. Na subida de versão nada muda — nenhum tenant tem escolha até o Admin salvar uma.- Mínimo abaixo de 1, máximo abaixo do mínimo, ou — com o padrão ligado —
retentionDaysfora dos limites recusam a subida. - Se você apertar os limites depois, a escolha salva fora deles é ajustada ao limite na hora de rodar, com uma linha
WARNno log nomeando tenant e tabela; o valor guardado não é reescrito. - Toda mudança de janela de um tenant fica na tabela
tenant_retention_changes(quem, qual tabela, de quê para quê, quando).
Fora do Helm, as variáveis são as da tabela acima, mais *_BATCH_SIZE, *_MAX_BATCHES e *_CRON de cada uma (FLOWABLE_HISTORY_RETENTION_BATCH_SIZE, AI_STEP_RESERVATION_RETENTION_MAX_BATCHES, ROBOT_EXECUTION_RETENTION_CRON…).
O que decidir antes de subir a versão:
- Apagar não é arquivar. Não há exportação antes da poda. Se algum cliente precisa do histórico de um processo encerrado por mais tempo que a janela — auditoria, regulação —, suba a janela ou desligue a tabela (
enabled: false) antes da primeira noite. - A primeira noite encontra o acúmulo.
batchSizeé o teto de umDELETE;maxBatchesPerRuné o teto de uma noite por tenant e tabela (com os padrões, até 100 000 instâncias de histórico por tenant numa noite). Um acúmulo maior é drenado ao longo de várias noites, em vez de segurar o lease numa passada longa. - A mineração de campos lê histórico. Uma janela de mineração (
timeWindowMonths) maior que a retenção do histórico só enxerga o que ainda está guardado. - Janela ou lote abaixo de 1 recusa a subida: para não apagar nada, use
enabled: false— e, se nenhum tenant pode guardar menos que o padrão, umminRetentionDaysigual a ele. - O timer de limpeza nativo do Flowable (
enableHistoryCleaning) continua desligado de propósito: o job que ele cria não carrega tenant, e esta plataforma recusa job sem tenant. A poda usa a consulta de limpeza do próprio motor (instâncias encerradas antes do corte), filtrada por tenant e com a janela de cada tenant.
Para medir o efeito, compare o tempo do agregado do dashboard antes e depois — ele conta por act_hi_taskinst e é o termômetro mais barato do tamanho do histórico.
Migrando uma instalação que já roda em LOCAL
O upgrade não move os arquivos. Eles estão no PVC; nem o Helm nem o backend copiam nada. Nada nessa mudança apaga um anexo, e nada nela copia um também. Os passos, nesta ordem:
- Crie o bucket no object storage que o cliente já opera, e uma chave que leia, escreva e apague dentro dele.
- Copie os arquivos do volume. Eles estão em
persistence.mountPath—/data/attachmentspor padrão — no formato<diretório>/<arquivo>, que é exatamente a chave S3 que oS3StorageProviderteria gravado. Ummc mirrorouaws s3 syncde/data/attachments/para a raiz do bucket reproduz o layout. Copie de um pod com o volume montado, ou de um snapshot; o backend pode continuar no ar. - Reescreva
attachments.storage_path. É o passo fácil de esquecer e impossível de pular: comLOCALa coluna guarda um caminho absoluto (/data/attachments/<diretório>/<arquivo>), comS3ela guarda a chave (<diretório>/<arquivo>).attachmentsé tabela por schema de tenant, então o update roda uma vez em cadatenant_<slug>, não uma vez só. - Só então faça o upgrade com os valores de storage preenchidos.
Para instalação pequena existe o atalho, e ele é um atalho mesmo: reenviar os anexos pela tela e deixar a plataforma gravar em S3. Funciona, gera linhas novas com chaves novas, e não escala além de algumas dezenas de arquivos.
Memória: robôs não rodam dentro do container do backend
Nenhum robô abre na JVM da aplicação: toda execução é atribuída a um runner cadastrado, que é outra máquina (ou outro pod) rodando o agente. backend.resources dimensiona a plataforma sozinha, e o teto de heap do sandbox é do agente, na máquina dele — o chart não tem chave para isso.
Quem dimensiona memória para robô dimensiona a máquina do runner. Uma instalação sem runner cadastrado não gasta memória com robô nenhum — e também não executa robô nenhum.
Cadastrar um runner é parte de subir a instalação
Sem pelo menos um runner cadastrado e respondendo, as execuções ficam PENDENTE para sempre, com o motivo escrito na própria linha. A plataforma não executa robô por conta própria.
No Helm o chart já resolve isso: ele sobe runner.replicaCount runners (1 por padrão) que geram a própria chave no próprio volume e se inscrevem sozinhos como GLOBAL. Só a inscrição feita de dentro do release faz isso, e ela só cria runner GLOBAL — quem segura o token ganha uma fatia do pool compartilhado, não o trabalho de um tenant.
A imagem do runner (backend/runner-agent/Dockerfile) agora é glibc e traz Python. Ela usa eclipse-temurin:21-jre-noble com python3 e python3-venv (Python 3.12; o pip vem do ensurepip do venv), e não mais a base Alpine: quase toda roda binária do PyPI (numpy, pandas, lxml) é manylinux, e num interpretador musl ela não instala. A imagem cresce por volta de 60 a 80 MB, o usuário continua o 1000, e o chart e o docker-compose.dev.yml não precisam de configuração nova: o agente acha o python3 no PATH. Navegadores (Playwright, Selenium) não vêm na imagem (seriam mais ~400 MB): quem precisa deles parte desta imagem, instala o navegador e libera o caminho com --python-env. O chart ainda não tem campo para passar argumentos extras ao agente.
Fora do Helm, e para qualquer runner de um tenant específico, o cadastro é manual, e continua sendo de propósito: decidir qual máquina recebe o código de um tenant é decisão de gente (specs/automation/robot-runners.md → How the chart installs runners).
O passo é uma vez por máquina, e está descrito com os comandos e a tela em Robôs → Cadastrando um runner. O que cabe a quem sobe a instalação é app.api.base-url: o agente confere na partida se alcança o RPC da plataforma e para com a mensagem do endereço se não alcançar. localhost ali é a própria plataforma, nunca a máquina do runner. O chart preenche esse endereço sozinho — a URL do Ingress quando há um, o Service interno quando não há — e app.apiBaseUrl nos values sobrescreve.
O runner não pode ser a máquina da aplicação
Instalar o agente no mesmo host do backend recoloca o código do tenant no disco que guarda a configuração, as credenciais e os anexos de todos os outros — que é exatamente a exposição que executar fora da aplicação evita. O chart não impede isso; a escolha é de quem instala.
O envelope de operação: o que uma instalação aguenta
Antes das contas, o resumo honesto: a escalabilidade desta plataforma é limitada pelo banco, e isso é consequência do isolamento por schema, não um defeito a consertar. Cada tenant tem o próprio schema, e é isso que garante que uma empresa não enxergue dado de outra. O que o banco cobra, porém, não cresce mais com o número de empresas: desde 2026-09-09 há um pool só por nó, e o custo é do pod.
Três limites decidem o tamanho de uma instalação. Só um deles é HTTP, e não é o que aperta.
| Limite | O que o fixa | Como se manifesta |
|---|---|---|
| Conexões de banco | (sharedPool + 1) × pods | FATAL: sorry, too many clients already numa requisição de usuário |
| Concorrência de passo de IA | AI_STEP_CORE_SIZE (8 por padrão), por instalação | fila de etapas de IA crescendo; a espera aumenta, e a etapa recusada por fila cheia vai para reprocesso |
| HTTP | CPU e memória do pod | raramente é o gargalo aqui |
Dimensionamento pelo banco. Com o padrão (sharedPool 10), cada pod custa 11 conexões — as 10 do pool mais a do barramento de cluster — e esse número não muda com o número de clientes. Quantos pods cabem, deixando de fora a folga que o Postgres reserva para superusuário e para a sua própria sessão de psql:
max_connections | Tier típico no Cloud SQL | pods que cabem, com qualquer número de tenants |
|---|---|---|
| 25 | db-f1-micro | 2 |
| 50 | db-g1-small — o do deploy gerenciado | 4 |
| 100 | 1 vCPU / 3,75 GB | 9 |
| 200 | 2 vCPU / 7,5 GB | 18 |
| 400 | 4 vCPU / 15 GB | 36 |
A conta, para conferir qualquer linha: (10 + 1) × pods.
Até 2026-09-09 esta tabela tinha uma coluna por formato de instalação e uma linha de tenants em cada célula, porque cada tenant abria o seu próprio pool HikariCP e a conta era (sharedPool + poolPorTenant × tenantsAtivos + 1) × pods. O pool por tenant foi removido: um pool só atende todo mundo, o search_path é aplicado quando a conexão é emprestada e a sessão é limpa quando ela volta. O cliente novo deixou de mover o número.
O teto chega com o pod — e isso é uma alavanca
Antes, redundância custava tenants: dois pods num db-f1-micro não sustentavam nem um cliente ativo, e o terceiro cliente derrubava a redundância de um db-g1-small. Agora o único fator é o pod, e o preço dele é fixo: mais um pod custa 11 conexões e traz mais um executor do Flowable junto. É por isso que escalar horizontalmente passou a ser o caminho para vazão de job — com o pool por tenant não era, porque cada pod multiplicava também a parcela dos tenants que ele servia.
O pool compartilhado tem piso, e o piso não é negociável
Um passo de IA segura duas conexões no pico (a dedução de cota é REQUIRES_NEW). Como todas as conexões saem do mesmo pool, ele precisa de app.flowable.executorMaxSize × 2 + 1 — 7 com o executor padrão. O chart recusa o render abaixo disso e a aplicação avisa no start, porque o erro que isso evita não é too many clients: é um tenant que simplesmente para de concluir etapas de IA, sem log e sem falha. O mesmo piso vale para app.tenantPool.maxSize, que é o teto de conexões que um tenant pode segurar dentro do pool compartilhado.
O que o pool compartilhado custa, medido e não suposto:
- ~1,8x no custo por consulta curta, numa bancada com quatro tenants alternando na mesma conexão (
SharedPoolCheckoutCostMeasurementTest, perfilperf). São ~51 µs de ida e volta a mais por empréstimo (aplicar osearch_pathe limpar a sessão) e ~15 µs de replanejamento, porque o PostgreSQL reanalisa a consulta quando osearch_pathmuda. Na escala de um passo de IA a diferença some: com chamadas de 1 s e 3 s a vazão sustentada é idêntica. - O anteparo é teto, não reserva. O pool por tenant garantia por construção que um tenant não consumia a conexão de outro. No lugar dele fica um teto por tenant dentro do pool compartilhado (
app.tenantPool.maxSize), exato por nó — mas um teto, não uma reserva: um tenant pode esperar por causa da rajada de outro.
Afinidade de tenant: um nó dono por tenant
app.tenantAffinity.enabled (desligada por padrão) dá a cada tenant um nó dono, que é o único a adquirir os jobs dele. O argumento de conexão da afinidade acabou junto com o pool por tenant: não existe mais parcela por tenant para deixar de multiplicar por pod, e o orçamento é idêntico com ela ligada e desligada. A linha de start diz isso em vez de calar sobre um benefício que não existe mais.
O que ela ainda compra, e o que pede em troca:
- Posse de job e localidade de plano. Um nó adquire só os jobs dos tenants dele, e o banco vê o mesmo tenant sempre pelo mesmo nó.
- O roteador na frente precisa distribuir por
X-Tenant-ID. O chart escrevenginx.ingress.kubernetes.io/upstream-hash-by: "$http_x_tenant_id"quandoapp.tenantAffinity.httpestá ligado, e recusa o render se oingressClassNamenão fornginx— outro controlador ignoraria a anotação em silêncio. - Um nó que cai devolve os tenants dele em 30–40 s (uma batida de heartbeat mais o TTL). Nesse intervalo os jobs desses tenants esperam; nada se perde e nada falha, mas eles atrasam. Detalhes, incluindo o que sai no log de cada nó, em
specs/core/tenant-node-affinity.md. - Instalação de um pod não muda em nada — com um destino só, o hash não tem o que distribuir.
Dimensionamento pela IA. Uma instalação executa 8 passos de IA ao mesmo tempo (AI_STEP_CORE_SIZE), e a vazão é core-pool-size da pool de IA × 60 ÷ latência_em_segundos. Com chamadas de ~20 s isso dá 24 passos por minuto — da instalação inteira, não por tenant. Acima disso a fila de 100 cresce e a espera de cada passo sobe; passando dela, a etapa é encerrada como falha e fica para reprocesso, em vez de ficar estacionada em silêncio. É aritmética: a bancada com latência injetada (specs/core/flowable-job-pool.md) mediu o desenho antigo, de 2 vagas.
O limite do provedor é outro teto, e costuma ser o primeiro. O provedor limita requisições por minuto e por dia por projeto e modelo, não por chave nem por tenant — no Gemini 3.1 Pro do tier 1 são 25 por minuto e 250 por dia. Cada etapa de IA faz uma requisição por rodada de ferramenta (cerca de cinco numa etapa típica). O limitador do modelo segura a rajada antes que ela vire 429:
- Configure
AI_RATE_LIMIT_REQUESTS_PER_MINUTEcom o RPM do tier dividido pelo número de nós que executam IA. O limite é por nó, em memória; dois nós com 25 cada mandam até 50 por minuto. Para um modelo específico useapp.ai.rate-limit.models[<nome do modelo>].requests-per-minute(e.max-concurrent-calls) noapplication.ymlou emSPRING_APPLICATION_JSON— nome com ponto precisa de colchetes. - Sem nada configurado, o primeiro 429 de taxa pausa o modelo naquele nó pelo tempo que o provedor pediu (
Please retry in 35s,retryDelay,Retry-After), e a etapa volta depois da espera, dentro do teto de tentativas dela. - Uma etapa segurada pelo limitador não chamou o provedor: não gasta tentativa nem gera linha de uso. Só vira falha depois de
AI_RATE_LIMIT_MAX_HOLD_MINUTESesperando. - 429 de cota esgotada (cota diária, de plano,
insufficient_quota, crédito acabado) continua final: esperar segundos não resolve. O motivo em Com erro diz qual dos dois foi. Só conta como cota quando o corpo diz isso; um 429 sem explicação (de um proxy, por exemplo) é tratado como limite de taxa: pausa curta e nova tentativa. - A fila aparece em
flowi.ai.model.limiter.waitingeflowi.ai.model.limiter.oldest.wait.seconds, por modelo, em cada nó.
O limitador torna o teto seguro; não o aumenta. Para subir o teto: tier maior no projeto do provedor, Vertex AI em vez do AI Studio em produção, e modelo Flash nas etapas que não precisam do Pro.
O que este desenho não resolve, e não vai resolver
Dito de uma vez, para ninguém descobrir em produção:
- Um tenant ocupado pode fazer outro esperar. A conexão não cresce mais com o número de clientes, mas o pool é um só: o teto por tenant impede que uma empresa consuma tudo, e não reserva conexão para quem está parado. Escrita concorrente pesada dentro de um mesmo tenant é o caso que este desenho não mediu — está registrado como limite conhecido em
specs/core/connection-pool-budget.md. - As tabelas do Flowable são compartilhadas. As 53
ACT_*vivem empublic, filtradas por tenant na consulta. Um tenant com histórico muito maior que os outros influencia o tempo de consulta de todos — o isolamento ali é de dados, não de desempenho. - A vazão de IA é do provedor. Nenhuma configuração nossa acelera uma chamada de 20 s. O que se escolhe é quantas cabem em paralelo — e desde 2026-09-09 cada uma segura uma thread da pool de IA, mas não uma conexão de banco durante a espera.
- Os números aqui são pior caso. Eles somam pools cheios ao mesmo tempo. Uma instalação real costuma ficar abaixo — e "costuma" não é base para dimensionar.
A receita, em três linhas
- Decida quantos pods a instalação vai rodar. O número de clientes não entra nesta conta.
- Escolha o tier pela tabela acima, com folga para a sua sessão de
psqle para o pico. - Só então decida réplicas: cada pod custa 11 conexões, sempre. Se faltar, o tier é a saída — não há mais o que separar por papel do lado das conexões.
Orçamento de conexões: só o pod multiplica
Existe um pool HikariCP por pod (spring.datasource.hikari.maximum-pool-size). Toda requisição de tenant sai dele: SchemaBoundDataSource aponta a conexão emprestada para tenant_<slug> e limpa a sessão quando ela volta. A demanda de conexões de uma instalação é:
(sharedPool + 1) × podsO + 1 × pods é o listener do bus de cluster (LISTEN flowia_cluster), que é o que faz uma atualização em tempo real publicada num pod chegar ao navegador conectado no outro. Custo fixo por pod, não por tenant.
Não há parcela por tenant. APP_DATABASE_EXPECTED_TENANTS continua existindo e continua saindo na linha de partida, mas não entra na soma: ele está ali para que o operador veja, lado a lado, o número de clientes que esperava e um orçamento que não se move com ele.
O chart templata isso, e sabe recusar
externalDatabase:
poolMaxSize: 10 # o ÚNICO pool de cada pod (piso: app.flowable.executorMaxSize × 2 + 1)
app:
tenantPool:
maxSize: 7 # teto que UM tenant pode segurar dentro daquele pool (TENANT_POOL_MAX_SIZE)
apiMaxSize: 3 # o mesmo teto num nó de papel `api`
database:
maxConnections: 200 # o max_connections do servidor. 0 = "não sei", e nada é conferido
expectedTenants: 8 # sai na linha de partida; não entra na conta
expectedPods: 0 # 0 deriva de backend.replicaCount + backend.worker.replicaCount
budgetEnforce: true # recusa o `helm template` em vez de recusar a conexãoCom budgetEnforce: true uma release que não cabe falha na renderização, imprimindo a soma e o teto, em vez de instalar e descobrir o limite como FATAL: sorry, too many clients already sob carga. expectedPods: 0 é o padrão e o certo: o chart conta os pods que ele mesmo cria, então o número que a plataforma recebe é o número que o cluster roda. Só preencha se houver pods de fora desta release compartilhando o banco.
A aplicação imprime a mesma conta, e também sabe recusar
O guard do chart pega a release antes de instalar. O nó pega o pod que foi configurado por fora dela — por backend.extraEnv, por manifesto editado à mão, ou por um banco cujo tier mudou embaixo dele. Em toda subida o backend imprime uma linha com o papel do nó e a decomposição, não só o total:
Orçamento de conexões (papel=all, pior caso): (10 compartilhado + 1 bus) = 11 por pod x 2 pod(s) = 22 conexões, contra APP_DATABASE_MAX_CONNECTIONS=50. Não há parcela por tenant: ...Os números vêm de APP_DATABASE_MAX_CONNECTIONS e APP_DATABASE_EXPECTED_PODS — que o chart preenche a partir de app.database.*. Passando do teto sai um aviso alto com o excedente e as alavancas que restaram; com APP_DATABASE_BUDGET_ENFORCE=true o nó recusa subir em vez de avisar. O padrão é false de propósito: recusar por padrão transformaria um número errado no values.yaml em indisponibilidade total, que é pior que o problema sendo relatado.
O número é pior caso, não consumo atual
É o teto que o pool compartilhado pode abrir; spring.datasource.hikari.minimum-idle decide quantas dessas conexões ficam de pé com o nó ocioso. Ler o aviso como "o banco já está cheio" leva a redimensionar a coisa errada.
As duas decisões, e quando cada uma é a certa
Quando a conta não fecha, sobraram duas saídas — e a que sumiu é a notícia:
| Saída | Quando é a certa | O que custa |
|---|---|---|
| Subir o tier do banco | Quando a instalação precisa de mais pods, por vazão de job ou por redundância | Dinheiro, e uma janela de manutenção no Cloud SQL |
Reduzir pods (backend.replicaCount, backend.worker.replicaCount) | Quando a vazão atual já basta e o que falta é conexão | Redundância, ou vazão de job — cada pod carrega um executor |
Não é mais uma alavanca de conexão. O nó api e o worker custam o mesmo: um pool cada. A separação continua valendo por isolamento de carga, não por conexão | — |
E a saída que deixou de ser necessária: subir o tier antes de um onboarding. O cliente novo não move mais o número; o que move é a decisão de rodar mais um pod, que é sua e não dele.
Encolher o pool tem piso
externalDatabase.poolMaxSize (e app.tenantPool.maxSize) não descem abaixo de app.flowable.executorMaxSize × 2 + 1. Abaixo disso os passos de IA se seguram e se esperam, e o sintoma não é erro: é o tenant parando de concluir etapa. Baixar o pool exige baixar FLOWABLE_EXECUTOR_MAX_SIZE junto — e aí a instalação executa menos passos de IA em paralelo.
Duas correções que valem conhecer:
- Até 2026-09-03 esta fórmula contava
sharedPooluma vez, e o pool compartilhado é por pod — cada pod é uma JVM com o seu. A versão antiga subestimava uma instalação de dois pods em um pool inteiro e lia "cabe" onde a verdade era "não cabe". - Até 2026-09-09 ela tinha um termo
poolPorTenant × tenantsAtivos. Ele saiu com o pool por tenant. Se você encontrar uma cópia de qualquer das duas fórmulas antigas, ela está errada. Registrado emspecs/core/connection-pool-budget.md.
O que o deploy gerenciado usa hoje
O deploy gerenciado roda dois pods contra um Cloud SQL db-g1-small (max_connections ≈ 50), com SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=10:
| pods | conta | contra 50 |
|---|---|---|
| 2 | (10 + 1) × 2 = 22 | cabe |
| 3 | (10 + 1) × 3 = 33 | cabe |
| 4 | (10 + 1) × 4 = 44 | cabe, com pouca folga para a sua sessão de psql |
| 5 | (10 + 1) × 5 = 55 | não cabe |
Em qualquer linha, com qualquer número de tenants. Até 2026-09-09 a mesma instância comportava dois clientes ativos e estourava no terceiro; e no db-f1-micro que ela era até 2026-09-05, o primeiro cliente já não cabia.
Trocar o tier reinicia a instância
gcloud sql instances patch --tier derruba as conexões abertas. Fazer isso com um cliente já na plataforma significa agendar indisponibilidade com ele. É por isso que a troca de tier é item de pré-onboarding e não de resposta a incidente.
TLS obrigatório e backup da instância gerenciada
Duas outras propriedades da instância gerenciada mudaram na mesma janela de 2026-09-05:
| Propriedade | Valor | O que isso obriga |
|---|---|---|
settings.ipConfiguration.sslMode | ENCRYPTED_ONLY | Conexão em texto claro é recusada pelo servidor, não rebaixada em silêncio. Quem conecta por IP precisa de sslmode=require na URL JDBC; o conector do Cloud SQL negocia TLS sozinho |
| Backup automatizado | diário, com recuperação a ponto no tempo | Há um ponto de retorno que não depende de alguém lembrar de rodar gcloud sql backups create |
Recuperação a ponto no tempo restaura a instância, não um cliente
Ela devolve o servidor inteiro a um instante: todos os schemas tenant_<slug> e o public, com as tabelas ACT_* e as tabelas por discriminador de todos os tenants juntos. Não existe "voltar o tenant acme para ontem" por esse caminho — usá-la para consertar um cliente reverte os outros junto.
O que existe para um tenant só é o par POST /api/admin/tenants/{id}/content-export / POST /api/admin/tenants/{id}/restore, que roda pg_dump -n tenant_<slug> e restaura sobre aquele schema. Isso não é um backup do tenant: o dump cobre só o schema dele, então instâncias em andamento, tarefas, jobs, histórico, agentes de IA, robôs, chaves de API e webhooks — que vivem em public — ficam de fora. Ver Multi-tenancy para quais tabelas ficam em cada lugar.
O piso do pool, e por que ele não desce sozinho
externalDatabase.poolMaxSize é a alavanca óbvia para caber em mais pods, e ela tem um piso que não é afinação, é correção. A dedução de cota de IA roda em transação própria (REQUIRES_NEW), que suspende a transação do job sem devolver a conexão dela e pede uma segunda. Cada passo de IA chega a segurar duas ao mesmo tempo:
piso = app.flowable.executorMaxSize × 2 + 1
= 3 × 2 + 1
= 7O + 1 é a conexão que precisa sobrar para o tráfego HTTP. O piso vale para o menor entre externalDatabase.poolMaxSize (o pool de onde o job tira conexão) e app.tenantPool.maxSize (o teto daquele tenant dentro dele): 20 conexões livres não ajudam um tenant cujo teto é 3, porque os passos dele se seguram entre si dentro do teto.
Abaixo do piso o sintoma não é erro, é o tenant parar de concluir etapa de IA: cada job segura a primeira conexão e espera a segunda, que só se libera quando algum outro job termina — e nenhum termina. O que aparece no log é timeout de conexão nas threads flowable-task-exec-*, sem nada que diga "o pool está pequeno demais".
O travamento em si começa mais cedo do que o piso sugere, e a distância é deliberada. Na bancada de specs/core/job-worker-nodes.md o pool para de progredir em executorMaxSize e volta a progredir em executorMaxSize + 1 — 4 com o executor de hoje. Entre 4 e 7 nada trava: os passos apenas esperam conexão. Os três de margem cobrem o que a bancada não modela, o lock de linha na única linha de cota do tenant, que faz deduções concorrentes enfileirarem com cada chamador ainda segurando a sua conexão. O guard é escrito em × 2 + 1 porque é esse o pior caso; + 1 é só o ponto onde ele deixa de ser fatal.
Por isso encolher o pool sozinho é recusado na renderização do chart, com a conta na mensagem. Encolher os dois juntos continua permitido e é a configuração que compra um pod a mais:
externalDatabase:
poolMaxSize: 5 # baixa o custo do pod para 6 conexões
app:
flowable:
executorMaxSize: 2 # baixa o piso para 5
tenantPool:
maxSize: 5O que se perde nessa troca é a terceira thread, que o ThreadPoolExecutor só cria quando a fila de 100 já está cheia — a última folga antes de um job ser rejeitado em vez de apenas atrasado. A vazão sustentada não muda: ela é executorCoreSize, que continua 2.
app.tenantPool.apiMaxSize fica fora dessa conta. Um nó api não adquire job, então nenhuma thread dele abre a transação de cota e nenhuma segura duas conexões — é isso, e não uma tolerância a mais, que permite o pool de 3 ali.
A derivação completa e as configurações medidas estão em specs/core/job-worker-nodes.md.
A role de banco: comum, nunca superusuária
A aplicação conecta com uma role de login comum. Ela é dona do schema public e tem CREATE no banco — o suficiente para as migrações, para o CREATE SCHEMA tenant_<slug> do provisionamento e para o pg_dump/psql do backup — e não tem SUPERUSER, BYPASSRLS, CREATEROLE, CREATEDB nem REPLICATION.
Isso importa por um motivo direto: uma role superusuária ignora permissão de schema e ignora Row Level Security, inclusive FORCE ROW LEVEL SECURITY. Com ela, nenhuma defesa de banco — atual ou futura — funciona, e todo o isolamento entre clientes passa a depender só do código da aplicação. SUPERUSER também habilita COPY … FROM PROGRAM, que executa comando no host do banco.
Confira a instalação com este comando, trocando o nome se você usa outro em externalDatabase.username:
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'flow-iagentic';Ou, direto do shell:
psql "$DATABASE_URL" -tAc \
"SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'flow-iagentic'"A resposta certa é flow-iagentic|f|f. Qualquer t significa que a instalação está sem barreira de banco entre clientes.
Para corrigir, como superusuário do banco:
ALTER ROLE "flow-iagentic" NOSUPERUSER NOBYPASSRLS NOCREATEROLE NOCREATEDB NOREPLICATION;
GRANT CONNECT, CREATE ON DATABASE "flow-iagentic" TO "flow-iagentic";
ALTER SCHEMA public OWNER TO "flow-iagentic";O backend confere isso sozinho em toda subida e imprime a linha:
Role de banco: flow-iagentic (rolsuper=false, rolbypassrls=false). Nenhuma defesa no nível do banco é ignorada por esta conexão.Quando a role tem privilégio demais, a mesma linha vira aviso e nomeia o atributo. APP_DATABASE_ROLE_ENFORCE decide o que acontece: false avisa e sobe — o padrão, porque numa instalação on-premise a role é decisão do DBA do cliente, e uma plataforma que não sobe é revertida, o que apaga o aviso junto; true recusa subir.
Onde cada padrão está, e por quê:
| Onde | Padrão | Motivo |
|---|---|---|
docker-compose.yml / .dev.yml | true | Ambiente que nós controlamos, e a correção é docker compose down -v |
docker-compose.prod.yml | false | Um volume existente não é reprovisionado; migre a role e então ligue |
Chart Helm (app.database.roleEnforce) | false | O chart é a entrega on-premise. helm/examples/production.yaml liga, depois de a consulta acima responder f e f |
O banco embutido do chart roda a aplicação como superusuário
postgresql.enabled=true sobe um StatefulSet cuja POSTGRES_USER é a própria role da aplicação — superusuária, por construção da imagem. Ele existe para uma avaliação começar em um comando, e o README do chart já diz que não tem backup, failover nem pooler. Isto é mais um motivo para não usá-lo em produção: use externalDatabase com uma role provisionada como acima.
Um volume de banco antigo não é reprovisionado
Os scripts de initdb só rodam num volume vazio. Um ambiente de desenvolvimento criado antes desta mudança continua conectando como superusuário — o backend recusa subir e diz por quê. docker compose down -v recria o banco do zero. É dado de desenvolvimento; num banco com dado real, use o ALTER ROLE acima em vez de apagar o volume.
Papéis de nó: quem responde HTTP e quem executa job
Uma imagem, dois trabalhos, escolhidos por configuração. O padrão é um nó fazendo os dois, que é o que toda instalação existente tem — a separação é opt-in e não muda nada para quem não pedir.
| Papel | Responde HTTP | Executa job Flowable | Tarefa agendada de cluster e CRON de robô | Migra o schema compartilhado | TENANT_POOL_MAX_SIZE padrão |
|---|---|---|---|---|---|
all | sim | sim | no nó eleito | sim | 7 |
api | sim | não | não | não | 3 |
worker | só /actuator | sim | no nó eleito | sim | 7 |
A variável é APP_NODE_ROLE, e o chart a escreve nos dois Deployments. Um nó que sobe sem ela é all — exatamente o comportamento de sempre, e é por isso que nenhuma instalação existente muda ao atualizar. Papel escrito errado recusa a subir, em vez de virar all e servir sob um papel que não é o configurado.
backend:
role: api # este Deployment só responde HTTP
replicaCount: 2
worker:
enabled: true # e este só executa job
replicaCount: 2
persistence:
enabled: false # quatro pods não montam um volume ReadWriteOnceUm nó api continua criando job: iniciar uma instância, completar uma tarefa e gravar uma variável escrevem em ACT_RU_JOB. Ele só nunca os adquire.
"Não roda tarefa agendada" quer dizer tarefa de cluster. O que separa uma da outra é o lease: dezesseis das dezenove tarefas agendadas disputam um lease em job_leases antes de rodar, e num nó api essa disputa é negada na hora, sem nem ir ao banco. As três que não disputam continuam rodando em todo nó de propósito, porque reparam ou publicam estado daquele nó: a reconciliação de delegates, a amostragem de saturação da fila de IA e o heartbeat de membership do nó (ClusterMembershipService). ScheduledWorkIsLeasedOrPerNodeTest mantém essa lista de três e reprova um @Scheduled novo que não faça nem uma coisa nem outra.
"Não migra" é o conjunto compartilhado no boot. O Flyway de schema de tenant continua ligado num nó api — é ele que cria o schema de um tenant novo e o migra na primeira requisição dele, e desligar isso entregaria um schema vazio dizendo que deu certo.
Quando separar. Não é sobre HTTP. A vazão que acaba é a de passo de IA: uma etapa fica segundos a minutos bloqueada no modelo segurando uma thread e uma conexão de tenant, e é esse número que cresce adicionando worker. Se o gargalo for requisição HTTP, backend.replicaCount já resolve.
Quem migra. Os papéis que executam job (all e worker) rodam o Flyway do schema compartilhado na partida; api nunca roda. Por isso backend.role: api sem backend.worker.enabled: true é recusado na renderização: ninguém executaria job e ninguém migraria, e nenhuma das duas falhas avisa — o trabalho simplesmente se acumula sem ser adquirido. A justificativa dessa escolha, e a alternativa que foi descartada (um Job de pre-upgrade), estão em specs/core/job-worker-nodes.md → Who migrates.
Nada ordena os dois Deployments
O Kubernetes sobe api e worker ao mesmo tempo. Um pod api pode subir enquanto a migração ainda roda; o certo é ele recusar readiness até o schema ser o que ele espera, e essa metade ainda não está implementada na aplicação. Até lá, num upgrade que traz migração, prefira subir a release com --wait e conferir o rollout do worker antes de mandar tráfego.
Segredos
Deixe secrets.jwtSecret e secrets.encryptionKey vazios e o chart os gera, relendo o Secret existente no upgrade para nunca rotacionar por acidente.
Guarde o APP_ENCRYPTION_KEY
Ele descriptografa credenciais já gravadas no banco: segredo de SSO, senha de SMTP, chave do provedor de IA (ai_models, rag_config), segredo de assinatura dos webhooks e toda variável de tenant marcada como secreta. Perdê-lo não falha na hora — falha horas depois, na primeira integração que tenta ler a própria senha. O Secret carrega helm.sh/resource-policy: keep para o helm uninstall não levá-lo junto, e as notas da instalação imprimem o comando de backup.
O que fica cifrado no banco
Credenciais de terceiros não são guardadas em texto claro. As colunas abaixo são cifradas com AES-256-GCM usando o APP_ENCRYPTION_KEY, na escrita e na leitura, de forma transparente para a aplicação:
| Coluna | O que guarda |
|---|---|
ai_models.api_key, rag_config.api_key | Credencial do cliente com o provedor de IA |
webhooks.secret | Chave que assina o HMAC das entregas |
tenant_variables.value_text | Variáveis do tenant, incluindo as marcadas como secretas |
sso_connections.client_secret, configurações de e-mail | Credenciais de SSO e do servidor de e-mail |
ACT_RU_VARIABLE.TEXT_, ACT_HI_VARINST.TEXT_ (tipo encryptedSecret) | Senha digitada num campo de formulário do tipo Senha |
No boot a aplicação cifra qualquer valor que ainda esteja em claro e registra quantos foram; o número esperado é zero.
A última linha é a única que expira: a senha digitada num formulário some das tabelas de execução quando a instância termina, e uma varredura noturna apaga a cópia do histórico depois de FORM_PASSWORD_HISTORY_RETENTION_DAYS dias (7 por padrão). Ela é a única credencial que não tem casa no cofre — nasce no meio de um processo e pertence a uma instância só.
O corpo de requisição nunca vai para o log
O log de requisições HTTP registra a partir do status 400 e nunca escreve o corpo enviado, em rota nenhuma. Isso não depende de marcar campo nem de listar caminho: um envio de formulário que falha carrega os valores digitados no corpo, e um deles pode ser uma senha. O que continua no log é método, caminho, query (com password e token ofuscados), cabeçalhos (com Authorization e X-API-Key ofuscados), status e o corpo da resposta — que é onde está o erro de validação que explica a falha.
Credenciais que o sistema apenas confere não são cifradas, e sim guardadas como hash, que é mais forte: senha de usuário, chave de API, token de sessão e token de e-mail. A diferença é se o valor original precisa voltar: a chave do provedor precisa ser reenviada a ele, uma senha nunca.
Cifra em repouso protege o banco, não a aplicação
Um dump de backup, uma réplica de leitura ou um acesso direto ao banco não revelam essas credenciais. Um processo comprometido da própria aplicação, sim — ele tem a chave e decifra por construção.
Recomendado: SECRETS_INJECT_INTO_PROCESS_VARIABLES=false
Com true (o padrão), variável secreta é copiada para as variáveis da instância e o Flowable a grava em texto claro no runtime e no histórico. Com false, ela não entra na instância — as expressões usam secrets.get(execution, 'chave'), e nenhuma credencial fica nas tabelas do motor. Ver Variáveis do Tenant.
Para gerenciar os segredos por fora, use secrets.existingSecret com as chaves: DB_PASSWORD, JWT_SECRET, APP_ENCRYPTION_KEY, S3_ACCESS_KEY, S3_SECRET_KEY.
GEMINI_API_KEY é opcional — inclua para configurar um modelo de IA inicial; sem ela a plataforma sobe e as funcionalidades de IA falham na chamada, com 409 e a frase dizendo onde cadastrar um modelo. Não a inclua vazia: variável vazia é definida, não ausente, e o Spring AI recusa subir contra uma. Era assim que o backend entrava em CrashLoopBackOff enquanto o helm install dizia que tinha dado certo.
Cofre externo
Por padrão toda credencial fica no banco, cifrada com o APP_ENCRYPTION_KEY. Quem já guarda credenciais num HashiCorp Vault pode ligar o cofre da instalação: a partir daí uma credencial pode ser gravada como referência — vault://<mount>/<caminho>#<campo> — e a plataforma lê o valor no cofre no momento do uso, sem guardar cópia. Sem cofre ligado nada muda, e a instalação sobe como sempre.
Hoje o único cofre suportado é o HashiCorp Vault (vault://). Azure Key Vault, AWS Secrets Manager e GCP Secret Manager ainda não são suportados.
secretBackend:
vault:
enabled: true
uri: https://vault.empresa.internal:8200
namespace: "" # namespace do Vault Enterprise, se houver
kvVersion: 2
auth:
method: kubernetes # ou approle
kubernetes:
role: flowia
mountPath: kubernetes
audience: vault
approle:
roleId: ""
mountPath: approle
secretIdSecretRef: { name: "", key: secret-id }
tenantPathTemplate: "kv/flowi/tenants/{tenant}/"
installationPathPrefix: "kv/flowi/platform/"
cacheTtl: 5mA plataforma se autentica com a própria identidade. Em kubernetes, o pod apresenta um token projetado da service account do chart, com a audiência audience; em approle, o roleId vai nos valores e o secret-id é montado como arquivo a partir do Secret nomeado — nunca vira variável de ambiente. Token estático não é aceito: o chart recusa renderizar, e o perfil prod recusa subir. Um endereço http:// também é recusado, a menos que allowInsecure: true.
Cada tenant só lê o próprio prefixo. Uma credencial de tenant (variável secreta, ${var.x} de robô ou de caixa de e-mail, secret de webhook) precisa apontar para dentro de kv/flowi/tenants/<slug-do-tenant>/; uma credencial da instalação (chave do modelo de IA, do embedding, senha do SMTP, client secret do SSO), para dentro de kv/flowi/platform/. A plataforma confere ao salvar e ao ler. Espelhe a mesma regra na política do cofre:
path "kv/data/flowi/tenants/*" {
capabilities = ["read"]
}
path "kv/data/flowi/platform/*" {
capabilities = ["read"]
}vault auth enable kubernetes
vault write auth/kubernetes/config kubernetes_host="https://kubernetes.default.svc"
vault policy write flowia flowia.hcl
vault write auth/kubernetes/role/flowia \
bound_service_account_names=<release>-flowia \
bound_service_account_namespaces=<namespace> \
audience=vault token_policies=flowia token_ttl=1hA política só dá leitura: a plataforma nunca escreve no cofre. Em KV versão 1 o caminho da política não leva o data/.
Cofre fora do ar falha visivelmente. O valor lido fica em memória, no próprio pod, por cacheTtl (5 minutos). Vencido o prazo, se o cofre não responde a leitura falha — não existe cópia velha de reserva: a etapa de IA vai para nova tentativa e, esgotadas, para o painel de falhas; a entrega de webhook fica para nova tentativa; o e-mail continua na fila; o login por SSO mostra que o segredo do provedor está num cofre indisponível, e o login com e-mail e senha continua funcionando. O estado aparece em /actuator/health/secretBackend como VAULT_UNREACHABLE, de propósito fora do agregado de /actuator/health: as sondas do chart batem ali, e um cofre fora do ar não pode reiniciar todos os pods.
Toda leitura que chega ao cofre é auditada, sem o valor. A tabela secret_read_audit guarda quando, qual tenant, para quê (modelo de IA, webhook, variável…), qual referência e qual etapa de processo leu, e o resultado; secret_read_hourly conta por hora todas as resoluções, inclusive as servidas do cache. As duas apagam o que passa de 90 dias.
Provedor de IA
O padrão é Gemini pela API compatível com OpenAI, mas qualquer endpoint compatível serve — vLLM, gateway interno, Ollama atrás de proxy:
ai:
baseUrl: http://llm-gateway.internal:8000/v1/
chatModel: qwen2.5-72b-instruct
embeddingModel: bge-m3
embeddingDimensions: 1024embeddingDimensions dimensiona a coluna do pgvector: mudar depois de indexar documentos exige reindexar toda a base de conhecimento.
OCR e ingestão da base de conhecimento
A imagem do backend traz o Tesseract e os idiomas por, eng e spa (tessdata_fast) — cerca de 47 MB a mais na imagem, medido em 2026-10-19. O OCR roda como processo separado, dentro do container: o documento não sai da instalação e não há token nem linha em ai_usage_logs. Uma imagem montada sem o binário funciona igual, só com o OCR desligado — a tela de upload diz qual é o caso.
O custo é CPU: estime 1 a 3 segundos por página a 300 DPI por núcleo, e RAG_OCR_CONCURRENCY processos por nó (1 por padrão). Uma carga grande de PDF digitalizado compete com as requisições do mesmo nó; numa instalação com papéis de nó, deixe a ingestão para o worker.
A janela de órfãos (RAG_ORPHAN_VECTOR_RETENTION_DAYS, 30 dias) agora também decide o custo de restaurar uma versão antiga de documento: dentro dela a restauração reaproveita os vetores e não gasta token; depois dela, o arquivo é reembutido. Encurtar a janela economiza disco e encarece a restauração.
O registro de acesso a material restrito das bases (kb_retrieval_log, uma linha por busca que devolveu ou negou trecho classificado) é podado todo dia, com lease, depois de RAG_RETRIEVAL_LOG_RETENTION_DAYS (365 dias; mínimo 1). É uma janela da instalação: não aparece na tela de retenção do tenant.
As fontes de cada etapa de IA (ai_rag_retrievals, no schema do tenant) saem junto com a instância, no mesmo lote do histórico do Flowable — portanto só são podadas quando a retenção do histórico está ligada. O parágrafo que pede ao agente para citar o trecho é escrito na língua de RAG_CITATIONS_INSTRUCTION_LOCALE (pt-BR por padrão; en ou es), porque o produto não tem língua por tenant.
Sincronização com SharePoint e OneDrive
Desligada por padrão: com KNOWLEDGE_SOURCE_PROVIDERS vazio (app.knowledge.sources.providers) nenhuma tela oferece o recurso, a API recusa conexão e o job não sincroniza nada. Para ligar, defina KNOWLEDGE_SOURCE_PROVIDERS=microsoft.
Os hosts são fixos por nuvem, não configuráveis e não digitados por ninguém — libere-os no firewall e no proxy de saída:
| Nuvem | Hosts |
|---|---|
GLOBAL | login.microsoftonline.com, graph.microsoft.com, *.sharepoint.com, *.files.1drv.com |
US_GOV | login.microsoftonline.us, graph.microsoft.us, *.sharepoint.us |
CHINA | login.chinacloudapi.cn, microsoftgraph.chinacloudapi.cn, *.sharepoint.cn |
Os *.sharepoint.* e *.files.1drv.com são os destinos dos downloads (o Graph redireciona para eles, sem o token). Qualquer outro host — inclusive num link devolvido pelo próprio Graph — é recusado, e o SsrfValidator continua barrando endereço interno. Proxy de saída: as propriedades padrão da JVM (-Dhttps.proxyHost=… -Dhttps.proxyPort=… em JAVA_TOOL_OPTIONS) valem para estas chamadas.
| Variável | Padrão | O que faz |
|---|---|---|
KNOWLEDGE_SOURCE_PROVIDERS | vazio | microsoft liga SharePoint e OneDrive |
KNOWLEDGE_SOURCE_MAX_ITEMS_PER_RUN | 500 | downloads por rodada antes de pausar numa fronteira de página e continuar no minuto seguinte |
KNOWLEDGE_SOURCE_CONCURRENCY | 2 | fontes sincronizando ao mesmo tempo por nó |
KNOWLEDGE_SOURCE_RUN_RETENTION_DAYS | 90 | idade máxima das linhas de kb_source_run (histórico de rodadas) |
KNOWLEDGE_SOURCE_MAX_RETRY_ATTEMPTS | 10 | tentativas de um download que falhou antes de a falha ficar permanente |
O job (knowledge-source-sync, a cada minuto) disputa lease: num cluster com papéis, roda só no nó worker. O segredo do app do Entra fica numa variável secreta do tenant (valor ou referência vault://) e é lido a cada token; o token fica só em memória, em cada nó, até expirar. A migração compartilhada V0141 cria knowledge_connection e knowledge_source, com a reivindicação exclusiva de cada fonte; a de tenant V0034, as tabelas de itens, rodadas e falhas, com a fila de novas tentativas. Um download tem prazo total de 10 minutos e uma resposta JSON do Graph, teto de 16 MB.
Bases de conhecimento externas
Uma base externa consulta um Elasticsearch ou OpenSearch do cliente a cada etapa (Bases de Conhecimento). Quem abre a rede é o operador, não o tenant:
| Variável / valor Helm | O que faz | Padrão |
|---|---|---|
KNOWLEDGE_EXTERNAL_ALLOWED_INTERNAL_HOSTS / app.knowledge.external.allowedInternalHosts | destinos de rede interna que uma base externa pode consultar, como host e porta (es.corp.internal:9200, 10.20.0.5:9200). Um host sem porta vale só para a porta padrão do esquema (443 em https). Fora desta lista, endereço interno é recusado | vazio |
KNOWLEDGE_EXTERNAL_ALLOW_INSECURE_INTERNAL / app.knowledge.external.allowInsecureInternal | aceita http para um host da lista acima. Todo outro destino exige https | false |
KNOWLEDGE_EXTERNAL_EGRESS_DEFAULT_POLICY / app.knowledge.external.egressDefaultPolicy | deny recusa toda base externa de tenant sem lista de destinos; allow aceita qualquer host https público quando o tenant não declara lista | deny |
KNOWLEDGE_EXTERNAL_MAX_RESPONSE_BYTES | teto da resposta do índice; acima dele a consulta falha sem ser lida | 1048576 |
KNOWLEDGE_EXTERNAL_MAX_CONCURRENT_CALLS | consultas externas simultâneas por nó; a que não acha vaga dentro do próprio tempo limite falha como indisponível | 32 |
KNOWLEDGE_EXTERNAL_TEST_QUERIES_PER_MINUTE | quantas vezes por minuto uma pessoa pode usar Testar consulta | 20 |
O nome do cluster é resolvido uma vez por consulta, todos os endereços são conferidos, e a conexão vai para um desses endereços já conferidos — o nome original segue no Host e no SNI, e o certificado é verificado contra ele. Um DNS que muda de resposta entre a conferência e a conexão não redireciona a consulta para a rede interna. Essa fixação vale só para as bases externas: webhooks, a ferramenta HTTP do agente e o SSO conferem todos os endereços do nome e depois deixam o cliente HTTP deles resolver de novo para conectar — ali a janela de DNS rebinding continua, limitada pela lista de destinos de cada um. O certificado é conferido contra o trust store da JVM: um cluster com CA própria precisa dessa CA no trust store, como o cofre. Redirecionamento nunca é seguido, e a resposta inteira, corpo incluído, tem de chegar dentro do tempo limite da base. A contagem de consultas (knowledge_external_calls_hourly) é gravada por nó a cada 5 s e podada todo dia, com lease, depois de 400 dias. Nenhuma chamada a modelo é feita por essas consultas.
Cluster sem internet
cd helm
make mirror REGISTRY=registry.corp.internal/flowia TAG=1.0.0
helm install flowia ./flowia -n flowia --create-namespace -f examples/airgapped.yamlNada mais sai para fora: não há subchart remoto nem init container que baixe qualquer coisa.
Operação
cd helm
make status # release, pods, services, volumes
make logs # acompanha o backend
make smoke # helm test: health do backend + SPA de dentro do cluster
make upgrade VALUES=meu.yamlDepois de instalar, entre como SUPER_ADMIN e cole a licença da instalação em Plataforma → Licença. A verificação é offline: não há servidor de ativação nem necessidade de saída para a internet.
Testes do chart
cd helm && make testAs asserções só precisam do helm — sem cluster, sem plugin, sem rede. Renderizam o chart sob vários conjuntos de valores e conferem formato dos objetos, ligação do banco, rotas do ingress, modos de storage, tratamento de segredos e cada guarda que precisa falhar na renderização em vez de no pod.
O CI roda isso a cada push, mais kubeconform -strict contra os schemas reais da API do Kubernetes e o helm package, publicando o .tgz como artefato do build.
Flyway — Migrações de banco
Dois conjuntos, aplicados em momentos diferentes:
| Caminho | Escopo | Quando roda |
|---|---|---|
src/main/resources/db/migration/ | Schema public (compartilhado) | No start, e só nos papéis que executam job (all e worker) — um nó api assume o schema já migrado |
src/main/resources/db/tenant/ | Schema de cada tenant | No provisionamento do tenant; na primeira requisição daquele tenant em cada nó; e na varredura de fundo para o tenant sem tráfego |
Nenhum nó migra tenant nenhum no start
Desde 2026-08-28 o tempo de boot deixou de crescer com a quantidade de tenants. A consequência para quem faz deploy é que a migração de um schema de tenant chega pela primeira requisição dele depois do release, ou pela varredura — não pela subida do pod. Ver Multi-tenancy.
O caminho do tenant é db/tenant/, não db/migration/tenant/
O Flyway compartilhado aponta para classpath:db/migration e varre tudo que estiver aninhado ali. Uma migração de tenant colocada em db/migration/tenant/ seria executada também no schema public — exatamente o oposto do que ela deveria fazer. Veja Multi-tenancy para o mecanismo completo.
Toda migração precisa ser legível pela versão anterior
Em produção a revisão nova sobe sem tráfego, passa pelo smoke e só então recebe os usuários (ver Smoke pós-deploy). Ela roda o Flyway ao subir, no banco compartilhado: se o smoke reprovar, o banco já foi migrado e a versão anterior continua atendendo sobre ele. Por isso mudança que quebra schema vai em duas versões — primeiro expande (coluna nova, as duas escritas), depois contrai (remove a antiga). DROP ou RENAME do que a versão anterior ainda lê transforma um smoke reprovado em indisponibilidade. A tabela completa está em skills/backend/flyway-migration.md.
A migração das versões de documento reescreve o vector_store
V0136 (bases globais, em public) e V0028 (cada tenant) criam as tabelas de documentos e versões e atualizam o metadado de todo trecho de base de conhecimento já indexado, para dizer a que documento e versão ele pertence. É um UPDATE do vector_store inteiro numa transação: enquanto roda, ingestão e busca daquele schema esperam a trava. Num tenant com centenas de milhares de trechos isso pode levar minutos — ensaie sobre uma cópia (abaixo) e programe a janela.
Ensaiar a migração sobre uma cópia antes de subir
Migração que só cria tabela não precisa de ensaio. Migração que reescreve linha existente precisa: o número de linhas afetadas — e as que ela não vai conseguir converter — só aparece sobre os dados de verdade. Foi o caso de V0097 (o tenant_id_ das definições passa do UUID para o slug, em quatro tabelas do Flowable) e de V0098 (robots.parameters vira jsonb, e o que não for objeto JSON é anulado).
./deploy/rehearse-migration.sh --dump backup.sqlO script sobe um Postgres descartável, restaura o dump, imprime o estado antes, aplica as migrações pendentes e imprime o estado depois. O container é removido ao final, com ou sem erro, e o dump não é alterado. Aceita pg_dump em texto ou no formato próprio, e --migration V0097 limita quais rodar.
O relatório responde três perguntas: quantas linhas cada tabela move, quantas não casam com tenant nenhum e portanto ficam como estão, e quantos robôs perderiam os parâmetros por não serem um objeto JSON. Esse último número é o que vale uma conversa antes do deploy: são robôs que vão precisar de reconfiguração pela tela.
Atualizando para a busca híbrida: V0031 segura escrita em vector_store
A migração de tenant V0031 (e a V0139, para public) cria o índice de palavras e o preenche a partir dos trechos que já existem, dentro da própria migração. Enquanto o preenchimento roda, a transação da migração segura o vector_store daquele tenant: ingestões e qualquer escrita nele esperam, enquanto a leitura segue. A conta medida é de cerca de 22 s por 134 mil trechos (≈3,3 mil caracteres cada); um tenant pequeno nem percebe. Suba a versão fora do horário de ingestão pesada, ou ensaie com rehearse-migration.sh para saber o tempo do seu maior tenant. Nenhum token é gasto e nenhum vetor é reescrito.
Definições com script: recusadas no deploy, e o que fazer com as antigas
O que a plataforma garante, e o que não garante. O motor de JavaScript (nashorn-core) saiu do core, e o deploy, a publicação, a importação de template global e a implantação de decisões DMN respondem 422 a: scriptTask, elemento ou atributo script / scriptFormat, type="script" em listeners e tarefas CMMN, language= diferente de juel, DMN com expressionLanguage de script, qualquer flowable:type / activiti:type fora de dmn, java e case (portanto shell, http, mail, camel e mule), class= que não seja classe da plataforma, serviço web (implementation="##WebService", operationRef, <interface>, <operation>, <import> de WSDL) e expressões ${...} que citem reflexão, o motor ou jdbcTemplate / dataSource.
Isso não é "a plataforma nunca avalia código": ela avalia expressões ${...}, e o que protege é uma caixa dentro do motor, que vale também para definições já gravadas e para o que o deploy não consegue ler ('a' + 'b'). A caixa: (1) nenhuma expressão chama métodos ou lê propriedades de Class, ClassLoader, java.lang.reflect.*, java.lang.invoke.*, Runtime, Process, ProcessBuilder, Thread, System, nem getClass em qualquer objeto, nem os serviços do motor (getProcessEngineServices, getCommandContext...); (2) as expressões só alcançam os beans secrets e requiredVariables e os beans que são delegates ou listeners (JavaDelegate, ExecutionListener, TaskListener, delegates e listeners do CMMN), nunca jdbcTemplate, dataSource ou repositórios; (3) o motor recusa, ao interpretar o XML, tarefa de script, tipos de etapa fora da lista, listener script e class= fora da plataforma. Vale para os motores de processo, caso e decisão. O que continua possível a quem pode implantar: ${...} com execution.setVariable, condições sobre variáveis, delegateExpression para delegates e listeners da plataforma. Um modelador não consegue executar código arbitrário no servidor por esse caminho; delegates enviados por JAR, que rodam na JVM, continuam sendo ato de quem administra a plataforma.
Definições já implantadas com script continuam no banco. Sem o motor, a instância que chega na etapa de script falha com Can't find scripting engine for 'javascript', e a publicação de uma nova versão que ainda tenha script é recusada. Para listar o que precisa de correção, rode no banco da plataforma (as tabelas ACT_* ficam no schema public):
SELECT 'BPMN' AS kind, d.tenant_id_, d.key_, d.version_, d.id_ AS definition_id
FROM act_re_procdef d
JOIN act_ge_bytearray b ON b.deployment_id_ = d.deployment_id_ AND b.name_ = d.resource_name_
WHERE convert_from(b.bytes_, 'UTF8')
~* '(<([a-z0-9_]+:)?scriptTask[ >/])|scriptFormat=|(<([a-z0-9_]+:)?script[ >])|type="script"'
UNION ALL
SELECT 'CMMN', d.tenant_id_, d.key_, d.version_, d.id_
FROM act_cmmn_casedef d
JOIN act_cmmn_deployment_resource r ON r.deployment_id_ = d.deployment_id_ AND r.name_ = d.resource_name_
WHERE convert_from(r.resource_bytes_, 'UTF8')
~* '(<([a-z0-9_]+:)?script[ >])|scriptFormat=|type="script"'
UNION ALL
SELECT 'DMN', d.tenant_id_, d.key_, d.version_, d.id_
FROM act_dmn_decision d
JOIN act_dmn_deployment_resource r ON r.deployment_id_ = d.deployment_id_ AND r.name_ = d.resource_name_
WHERE convert_from(r.resource_bytes_, 'UTF8') ~* 'expressionLanguage="(groovy|javascript|js)"'
ORDER BY 1, 2, 3, 4;Para cada linha, abra o processo no modelador, troque a etapa de script por uma tarefa de serviço com expressão, um delegate ou uma decisão DMN, e implante e publique a nova versão. A versão antiga com script só deixa de rodar quando uma versão nova é publicada no lugar dela.
Super Admin inicial
O usuário inicial é criado pela migração V0001__init.sql, que roda sozinha no primeiro startup — não há passo manual a executar.
| Campo | Valor |
|---|---|
admin@flowiagentic.local | |
| Senha | Admin@1234 |
| Papel | SUPER_ADMIN |
O e-mail não tem hífen (flowiagentic, não flow-iagentic) — o nome do banco de dados tem, e é daí que vem a confusão. Tentar entrar com a versão hifenizada devolve credencial inválida.
Altere imediatamente em produção
A senha padrão é conhecida e deve ser alterada antes de expor a aplicação.
Build de produção (Frontend)
cd frontend
npm run buildOs arquivos estáticos ficam em frontend/dist/ e podem ser servidos por qualquer servidor web (nginx, Apache, CDN).
CI/CD
O CI roda em GitHub Actions, com os workflows em .github/workflows/. Não há azure-pipelines.yml no repositório.
| Workflow | O que faz |
|---|---|
ci.yml | Validação em todo push para master/main e em todo pull request |
deploy-gcp-backend.yml | Deploy do backend no GCP |
security.yml | Semgrep e OSV a cada push; Dependency-Check e SBOM duas vezes por semana |
deploy-gcp-frontend.yml | Deploy do frontend no GCP |
deploy-gcp-docs.yml | Deploy desta documentação |
deploy-gcp-landing.yml | Deploy do site de marketing |
production-smoke.yml | De hora em hora: o smoke contra produção e a conferência de que o que está no ar não ficou para trás do master |
O ci.yml tem dois jobs paralelos:
- Backend (Java 21) — sobe um Postgres
pgvector/pgvector:pg16como service e rodamvn verify, excluindoS3StorageProviderTesteAttachmentServiceTest, que dependem de armazenamento externo. Omvn verifynão sobe a aplicação e não gera oopenapi.json: esse artefato vive no profileopenapi, descrito em API REST. - Frontend (TypeScript) —
npm run build,npm run test:run,npm run i18n:check,npm run i18n:literalsenpm run lint:check. O passo de tipos é a build de produção inteira, nãotsc --noEmit: sozinho ele ignora as project references e deixa passar erro que a imagem de produção pega.
O i18n:check é bloqueante: uma string nova sem tradução em pt-BR, en e es reprova o build.
O i18n:literals cobre o buraco que o anterior não alcança. O i18n:check compara chaves entre locales; uma string que nunca passou por t() não é chave nenhuma, então não há o que comparar e ela atravessa o passo intacta — foi assim que o mesmo defeito foi corrigido uma vez e voltou. O i18n:literals lê cada .tsx com o parser do TypeScript e conta literal de texto nas quatro props que chegam à tela: label, placeholder, title e aria-label. Chave, variável e template não contam; só literal cru. O teto por prop fica congelado em frontend/scripts/ui-literals-baseline.json, com o mesmo contrato do lint: dívida velha passa, um literal novo reprova, e o número só desce — npm run i18n:literals -- --update-baseline trava o ganho depois de uma rodada de tradução. Exceções não são silenciosas: arquivo de teste sai por EXCLUDED_PATHS e um site específico só escapa declarado em DELIBERATELY_LITERAL, com motivo escrito, no topo de scripts/check-ui-literals.mjs.
O lint:check é bloqueante de outro jeito. O ESLint tem dívida antiga (368 achados), e reprovar todo build por causa dela só ensinaria a ignorar o passo; então a contagem por regra fica congelada em frontend/scripts/lint-baseline.json. Dívida velha passa; regra que cresce reprova; e regra que ninguém violava tem teto zero, de modo que uma categoria nova de problema não entra em silêncio. Corrigiu achados? npm run lint:check -- --update-baseline trava o ganho — o comando se recusa a subir um teto, só a baixá-lo.
Smoke pós-deploy
O CI prova o código; o smoke prova a instalação. O teste do chart roda antes do deploy, contra MinIO e sem provedor de embedding — por construção não enxerga o bucket, a chave nem a configuração de produção. Em 2026-10-01 isso deixou passar um upload que dava 403 no GCS havia quatro semanas.
Como o deploy funciona agora, nos três serviços (backend, frontend, docs):
- a revisão nova sobe no Cloud Run sem tráfego, com a tag
candidatee uma URL própria; - o smoke roda contra essa URL;
- passou:
update-traffic --to-latestleva os usuários para ela. Reprovou: o tráfego fica na revisão anterior, a tag é removida e o workflow fica vermelho.
O smoke do backend é deploy/smoke/post-deploy-smoke.sh, e cada passo prova uma peça do ambiente:
| # | Passo | O que prova |
|---|---|---|
| 1 | GET /actuator/health responde UP | O processo subiu e alcança o banco |
| 2 | Login e select-tenant no tenant smoke | JWT_SECRET e a cadeia de autenticação |
| 3 | Sobe um anexo, baixa e compara os bytes | O bucket real, com as credenciais reais |
| 4 | Sobe um .md na base de conhecimento e espera um chunk novo (até 60 s) | Configuração de RAG, provedor de embedding, chave e a ingestão |
| 5 | Inicia smoke_ping, assume e conclui a tarefa, confere que a instância terminou | Flowable no tenant e o catálogo |
| 6 | Remove o anexo e o documento | O tenant smoke não cresce |
O frontend e o docs passam por deploy/smoke/static-site-smoke.sh: GET / responde 200 e todo /assets/* citado no index.html também.
O smoke não chama modelo de chat. O passo 4 gera embedding de um texto de umas cinquenta palavras e é contabilizado como RAG_EMBEDDING do tenant smoke na tela de governança.
Quando o smoke fica vermelho
- No deploy: produção não mudou — o usuário continua na revisão anterior. A linha
FAILdo log diz o passo e traz o corpo da resposta. Passo 3 é storage (bucket, credencial HMAC); passo 4 é RAG (Admin → Configurações de IA → Banco de Vetores, e o log deRagIngestionAsyncService). - Na rodada de hora em hora: produção está quebrada sem deploy — chave revogada, cota estourada, permissão de bucket alterada. O GitHub avisa a conta que mexeu no cron por último.
- Na conferência de defasagem: um serviço está servindo um commit com mais de 24 horas de atraso em relação a um commit do
masterque mexeu nos arquivos dele. Quase sempre é CI vermelha bloqueando o deploy — confira a issueci-red-on-master, aberta peloci.ymlquando omasterfalha e fechada sozinha na próxima rodada verde.
Rodar à mão
SMOKE_EMAIL=... SMOKE_PASSWORD=... ./deploy/smoke/post-deploy-smoke.sh https://app.flowiagentic.comContra app.flowiagentic.com, /actuator cai na SPA; passe o health do backend em SMOKE_HEALTH_URL (a URL *.run.app do serviço flowia-backend + /actuator/health).
Preparar uma instalação
Uma vez por ambiente, com o super admin:
SUPER_ADMIN_EMAIL=... SUPER_ADMIN_PASSWORD=... SMOKE_EMAIL=... SMOKE_PASSWORD=... \
./deploy/smoke/bootstrap-smoke-tenant.sh https://app.flowiagentic.comCria o tenant smoke, o usuário do smoke como MODELER só desse tenant e o processo smoke_ping. Pode rodar de novo sem duplicar nada. O mesmo par SMOKE_EMAIL/SMOKE_PASSWORD vai para os secrets do GitHub Actions.
O próprio smoke é testado antes de proteger produção: o job Chart installed in a real cluster roda deploy/smoke/ci-cluster-check.sh, que aponta o RAG para um provedor de embedding falso, exige o smoke verde e depois o roda de novo com o MinIO desligado, exigindo que ele falhe no passo 3.