Ir para o conteúdo
Trilha
Capítulos

Aprender

Desenvolvimento agentic — o control plane

Compartilhe a fila entre um time e uma frota de workers com o trilha-cloud, e como é o contrato se você rodar o seu.

Um runner numa máquina serve uma pessoa. Um time com várias máquinas, vários projetos e agentes trabalhando em paralelo precisa de um lugar que responda o que está na fila, quem está executando o quê, que evidência aquela execução deixou e quem pediu — sem que o código de projeto nenhum saia da máquina que o guarda. Isso é o trilha-cloud, e é privado: protocolo e runner são abertos para que qualquer ferramenta os fale; o control plane é onde operar uma frota custa alguma coisa.

                    trilha-spec (protocolo)
                           │
          ┌────────────────┼────────────────┐
          ▼                ▼                ▼
   trilha-runner      Claude Code      outro runner
          │                │                │
          └────────────────┼────────────────┘
                           ▼
                     trilha-cloud
     projetos · fila · frota · evidência · auditoria

O trilha-cloud é ele mesmo um app Trilha — rotas por arquivo, trilha gen, auth.APIKeys, c.Audit —, o que faz deste capítulo também o maior exemplo do framework em uso.

O fluxo#

Se você tem acesso ao repositório, um processo é o control plane inteiro:

export TRILHA_SECRET=$(trilha secret)
export TRILHA_CLOUD_ADMIN_TOKEN=troque-me
export TRILHA_CLOUD_DATA=./data/cloud.json
make dev                                  # http://localhost:3000

O operador registra um projeto e emite uma chave para os workers. Administração fica atrás do token do ambiente — a primeira chave tem que vir de algum lugar — e todo o resto atrás de chaves com escopo:

curl -X POST localhost:3000/api/admin/projects -H "Authorization: Bearer troque-me" \
     -d '{"name":"agenda","org":"aprender","repo":"git@github.com:voce/agenda"}'
curl -X POST localhost:3000/api/admin/keys -H "Authorization: Bearer troque-me" \
     -d '{"name":"notebook","project":"agenda","expires_days":30,"scopes":["runs:read","runs:write"]}'
# {"key":"tc_…"}   mostrada uma vez; o segredo é temperado com TRILHA_SECRET e nunca guardado

Quem tem chave enfileira uma task — a mesma TASK-002 dos capítulos anteriores:

curl -X POST localhost:3000/api/runs -H "Authorization: Bearer tc_…" \
     -d '{"project":"agenda","task_id":"TASK-002"}'
# {"id":"run-000001","status":"queued",…}

E um worker — um trilha-runner numa máquina com checkout da agenda — pega:

cd agenda
trilha-runner worker --cloud http://localhost:3000 --token tc_… --project agenda --once
# worker notebook on http://localhost:3000, project agenda
# TASK-002 → review (driver exec, 48s)
# branch trilha/task-002 in .trilha/runs/TASK-002/wt
# ✓ go test ./... (exit 0)

O worker pediu a próxima execução, executou localmente exatamente como no capítulo do runner e reportou status, branch, commit e os registros de evidência. Abra http://localhost:3000: o painel mostra o projeto, a execução em review com o branch, e a frota — notebook, ocioso, visto há segundos. A aprovação do revisor é DELETE /api/runs/run-000001 (review → done): a mesma decisão humana que o protocolo mantém fora das mãos do agente.

Todo passo está na trilha de auditoria, com a chave que o fez como ator:

curl localhost:3000/api/admin/audit -H "Authorization: Bearer troque-me" | jq '.[].action'
# "run.closed" "run.finished" "run.claimed" "run.enqueued" "apikey.emitiu" "project.created"

Reproduza o fluxo homologado#

O repositório do Cloud contém um aceite executável do ecossistema inteiro, não um fixture pronto. Cada execução cria uma aplicação e um protocolo novos:

ComponenteO que o aceite exercita
trilhanew, openapi, os dez gates de check (de gen a surface), ctx, build, a página e a API gerada
trilha-specinit, spec new, task add, task move, doctor, context e evidence
trilha-runnerworker do Cloud, worktree isolado, driver echo, checks, branch e commit
trilha-cloudprojeto, chave, fila, claim, resultado, revisão, aprovação, auditoria, portal e persistência

Rode o aceite completo no checkout do trilha-cloud:

make homologate
# ou inclua o gate do Cloud e o aceite do portal em navegador headless
make check-all

O protocolo é gerado pela CLI pública em vez de ser copiado do repositório:

trilha-spec init "$APP_DIR"
cd "$APP_DIR"
trilha-spec spec new "Homologate the Trilha ecosystem"
trilha-spec task add "Execute the end-to-end ecosystem flow" \
  --spec 001-homologate-the-trilha-ecosystem \
  --agent coder \
  --accept "trilha-runner creates TRILHA_RUN.md inside an isolated worktree" \
  --accept "the generated Trilha application remains green" \
  --check "test -f TRILHA_RUN.md" \
  --check "go test ./..."
trilha-spec task move TASK-001 ready
trilha-spec doctor
trilha-spec context TASK-001 --json

Depois o Cloud enfileira a task gerada e chama um worker real:

trilha-runner worker \
  --cloud http://127.0.0.1:3901 \
  --token tc_… \
  --project homologation-app \
  --name homologation-worker \
  --once \
  --driver echo

O aceite exige que TASK-001 chegue a review com o branch trilha/task-001, um commit e evidências aprovadas para test -f TRILHA_RUN.md e go test ./.... A mesma evidência é lida por trilha-spec evidence TASK-001 --json e pela API do Cloud. Depois, o script aprova a execução, valida os seis eventos de auditoria desde a criação do projeto até run.closed, reinicia o plano de controle e confirma a persistência do status final done.

Para exercitar a interface, inicie o Cloud com make dev, abra http://localhost:3000/?lang=pt-BR, use Configurar acesso para guardar o token administrativo e a chave gerada na sessão do navegador, registre a aplicação, enfileire TASK-001, consulte as evidências depois que o worker terminar e selecione Aprovar. O código da aplicação permanece no checkout do worker durante todo o procedimento.

O driver determinístico echo mantém este aceite independente de provedor de modelo. Drivers de IA, sandboxes remotos, billing e implantação de produção pertencem a ambientes de aceite separados.

A parte de navegador também está disponível como make homologate-ui. Ela opera o portal real pelo Chrome DevTools: configura acesso, registra projeto, emite uma chave, enfileira execução, abre evidências protegidas e aprova a revisão.

Product Studio: crie o produto sem usar CLI#

No Trilha Cloud 0.2.0, o operador pode habilitar um workspace gerenciado e o usuário faz o ciclo inteiro pela interface. A CLI continua existindo por trás do Cloud, mas deixa de ser uma responsabilidade de quem está criando o produto.

O operador inicia o Cloud uma vez, informando onde os projetos podem ser criados e quais binários homologados serão usados:

cd trilha-cloud
mkdir -p data workspaces
export TRILHA_SECRET="$(trilha secret)"
export TRILHA_CLOUD_ADMIN_TOKEN='troque-por-um-token-administrativo'
export TRILHA_CLOUD_DATA="$PWD/data/cloud.json"
export TRILHA_CLOUD_WORKSPACE_ROOT="$PWD/workspaces"
export TRILHA_BIN="$(command -v trilha)"
export TRILHA_RUNNER_BIN="$(command -v trilha-runner)"
make dev

Abra http://localhost:3000/?lang=pt-BR, selecione Configurar acesso e informe o token administrativo. Depois selecione Novo produto e preencha:

  • nome cadastro-usuarios e organização trilha;
  • descrição Cadastro de usuários com login e troca de senha;
  • módulo example.com/trilha/cadastro-usuarios;
  • template app, idioma Português e driver Echo para uma homologação determinística — use AI somente quando o provedor estiver configurado no ambiente do runner.
Formulário do Product Studio para criar a aplicação
O usuário descreve o produto na UI; nenhum comando de geração é digitado por ele.

Ao selecionar Criar produto, o Cloud executa operações fixas e auditáveis:

  1. o Trilha gera a aplicação app, que já inclui login, convite, cadastro, perfil e troca de senha;
  2. o Trilha Spec inicializa .trilha/, grava a spec aprovada 001-product-foundation e cria TASK-001 pronta;
  3. o Cloud inicializa o Git e registra o ponto de partida;
  4. ao selecionar Iniciar build, o Trilha Runner abre o worktree, executa a task e devolve branch, commit, log e evidências ao Trilha Cloud.

Para revisar, use Emitir chave com os escopos runs:read e runs:write, selecione Usar nesta sessão, abra Detalhes e confira cada check. O botão Aprovar encerra review → done e grava run.closed na auditoria.

Evidências da execução aprovada no Trilha Cloud
A UI mostra task, worker, branch, commit, checks e log antes da decisão humana.

O modo gerenciado é opt-in. Nomes são validados, cada workspace precisa ser filho direto de TRILHA_CLOUD_WORKSPACE_ROOT, entradas do usuário nunca viram shell e os segredos do Cloud são removidos do ambiente dos processos filhos. Somente credenciais do provedor necessárias ao driver AI chegam ao runner.

Operadores reproduzem os aceites do mesmo fluxo com:

make homologate-studio  # Trilha → Spec → Runner → Cloud → aprovação
make homologate-ui      # fluxo real do portal em Chrome
make check-all          # segurança + ecossistema + Product Studio + UI

Tutorial: cadastro de usuários de ponta a ponta#

Este roteiro cria uma aplicação Trilha real com login próprio, convite de usuários e troca de senha; descreve o trabalho com trilha-spec; executa a task com trilha-runner; e usa o trilha-cloud para fila, evidências e aprovação. O Cloud recebe metadados da execução, nunca o código-fonte da aplicação.

Use dois diretórios irmãos e três terminais: um para o Cloud, um para o app e outro para o worker. Os exemplos reservam localhost:3000 para o Cloud e localhost:3100 para a aplicação.

1. Inicie e configure o Trilha Cloud#

Com um checkout autorizado do repositório trilha-cloud:

cd trilha-cloud
mkdir -p data
export TRILHA_SECRET="$(trilha secret)"
export TRILHA_CLOUD_ADMIN_TOKEN='troque-por-um-token-administrativo'
export TRILHA_CLOUD_DATA="$PWD/data/cloud.json"
make dev

Abra http://localhost:3000/?lang=pt-BR. Em Configurar acesso, informe primeiro o mesmo TRILHA_CLOUD_ADMIN_TOKEN. A API key pode ficar vazia até ser emitida. As credenciais ficam somente no sessionStorage daquele navegador e são enviadas como Bearer tokens às APIs do Cloud.

Diálogo Configurar acesso do Trilha Cloud
Configure o token administrativo e, depois de emiti-la, a API key do worker.

2. Gere a aplicação de cadastro de usuários#

Em outro terminal, na pasta que guardará o projeto:

trilha new cadastro-usuarios \
  --module example.com/cadastro-usuarios \
  --template app \
  --lang pt \
  --agents
cd cadastro-usuarios

O template app já entrega o fluxo que será homologado:

RotaResponsabilidade
/entrarconfere e-mail e senha e abre a sessão
/admin/usuarioslista pessoas, define papel e emite convite
/convite/{token}deixa a pessoa convidada definir a primeira senha
/perfilaltera nome, solicita troca de e-mail, troca senha e lista sessões
/sairencerra a sessão atual

Os pontos centrais ficam em app/entrar/page.go, app/admin/usuarios/page.go, app/convite/token_/page.go e app/perfil/page.go. O primeiro administrador é semeado por ambiente:

export TRILHA_SECRET='use-um-segredo-com-pelo-menos-32-bytes'
export ADMIN_EMAIL='admin@example.com'
export ADMIN_PASSWORD='senha-segura-123'

3. Configure o gate make check#

trilha check executa os gates do framework (gen, gofmt, vet, test, audit e, quando existe um documento versionado, openapi). Um Makefile pequeno deixa o binário substituível em CI sem duplicar a política:

TRILHA ?= trilha

.PHONY: check
check:
	$(TRILHA) check

Rode o gate com as mesmas variáveis usadas pela aplicação:

TRILHA_SECRET="$TRILHA_SECRET" \
ADMIN_EMAIL="$ADMIN_EMAIL" \
ADMIN_PASSWORD="$ADMIN_PASSWORD" \
make check

Para testar outra versão da CLI sem editar o arquivo:

make check TRILHA='go run github.com/emersonjoe/trilha/cmd/trilha@v0.123.0'

4. Descreva a entrega com trilha-spec#

Inicialize o protocolo e gere a spec:

trilha-spec init .
trilha-spec spec new "Cadastro e autenticação de usuários"

Complete .trilha/project.md com os comandos do projeto e edite .trilha/specs/001-cadastro-e-autenticacao-de-usuarios.md para registrar o problema, a mudança, o que ficou fora e os critérios de aceite. Em seguida, gere uma task executável:

trilha-spec task add "Validar cadastro login e troca de senha" \
  --spec 001-cadastro-e-autenticacao-de-usuarios \
  --agent coder \
  --accept "administrador consegue entrar" \
  --accept "administrador consegue convidar usuário" \
  --accept "usuário convidado define a primeira senha e consegue entrar" \
  --accept "usuário consegue trocar a própria senha" \
  --check "go test ./..." \
  --check "make check"

trilha-spec task move TASK-001 ready
trilha-spec doctor
trilha-spec context TASK-001 --json

doctor valida a estrutura do protocolo. context mostra exatamente o pacote que o runner entregará ao agente: projeto, constituição, spec, task e perfil do agente.

5. Versione o ponto de partida#

O runner cria um worktree e uma branch por task, portanto precisa de um repositório Git com um commit limpo:

git init
git add .
git commit -m "inicia aplicação de cadastro de usuários"

6. Registre o projeto no portal#

No Trilha Cloud, selecione Registrar projeto e informe:

  • Nome: cadastro-usuarios;
  • Organização: o identificador do seu time, por exemplo trilha;
  • Repositório: a URL Git ou uma referência local compreensível para o operador, por exemplo local:///workspace/cadastro-usuarios.
Diálogo Registrar projeto
O Cloud identifica o projeto; o checkout continua na máquina do worker.

O mesmo passo pode ser automatizado:

curl -X POST http://localhost:3000/api/admin/projects \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"cadastro-usuarios","org":"trilha","repo":"local:///workspace/cadastro-usuarios"}'

7. Adicione o worker#

Na seção Frota, selecione Adicionar worker, escolha cadastro-usuarios, dê ao worker o nome cadastro-usuarios-worker e confirme. Isso emite uma chave com os escopos runs:read e runs:write presa a esse projeto — o mesmo POST /api/admin/keys por onde qualquer chave passa — e mostra, uma única vez, o comando de instalação já montado a partir do projeto escolhido e o segredo tc_…, cada um com seu próprio botão de copiar.

Diálogo Adicionar worker
Escolher o projeto preenche o comando de instalação e emite uma chave de worker presa a ele.

Cole o comando na máquina que vai rodar o worker — ele instala o trilha-runner, o trilha-spec e o CLI do Claude Code, cria um usuário de sistema runner dedicado, gera uma deploy key e espera ela ser cadastrada no repositório, clona o checkout e sobe um serviço systemd reforçado que mantém o worker rodando:

curl -fsSL http://localhost:3000/install/worker.sh -o worker.sh && sudo \
  TRILHA_CLOUD=http://localhost:3000 \
  TRILHA_PROJECT=cadastro-usuarios \
  TRILHA_REPO=git@github.com:trilha/cadastro-usuarios.git \
  TRILHA_WORKER_NAME=cadastro-usuarios-worker \
  bash worker.sh
# cole a chave tc_… quando o script pedir — ela nunca é um argumento de linha de comando

TRILHA_DRY_RUN=1 imprime o mesmo plano — usuário de sistema, caminho/permissão/dono de worker.env, nome do unit do systemd, WorkingDirectory — sem tocar no host, sem exigir root nem pedir o token; é a forma de ler o que o script faria antes de rodá-lo de verdade. Dois passos continuam manuais depois que ele termina: sudo -u runner -H claude auth login e conferir o unit com systemctl status trilha-runner-cadastro-usuarios. O que o próprio projeto do worker precisar — um banco de dados, a chave de um provedor de IA — é configuração desse projeto, não do instalador.

8. Enfileire a task#

No portal, selecione Nova execução e escolha cadastro-usuarios; o diálogo busca GET /api/projects/cadastro-usuarios/tasks e preenche um seletor com cada task que .trilha/tasks/ do repositório lista, seu status e — quando o servidor recusaria enfileirá-la — o motivo: ainda não está ready, já tem uma execução em andamento ou depende de uma task sem run done. Escolha TASK-001 e confirme. Quando várias tasks estão ready ao mesmo tempo, Enfileirar todas as prontas envia cada uma em sequência e mostra quantas foram aceitas e quantas foram recusadas, em vez de um POST /api/runs de cada vez. Uma task que ainda não chegou na branch padrão não aparece no seletor; o diálogo mantém um campo de texto livre ("Outra task…") para esse caso.

A mesma operação pela API:

export TRILHA_CLOUD_API_KEY='tc_…'
curl -X POST http://localhost:3000/api/runs \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"project":"cadastro-usuarios","task_id":"TASK-001"}'

O worker que o instalador ligou no passo 7 pega essa task sozinho — acompanhe a chegada em Detalhes. Para reproduzir a mesma prova localmente e de forma determinística, sem depender de um provedor de IA nem do driver do worker instalado, rode um worker temporário em primeiro plano com o driver echo — é exatamente o que a homologação deste tutorial usa:

cd cadastro-usuarios
TRILHA_SECRET="$TRILHA_SECRET" \
ADMIN_EMAIL="$ADMIN_EMAIL" \
ADMIN_PASSWORD="$ADMIN_PASSWORD" \
trilha-runner worker \
  --cloud http://localhost:3000 \
  --token "$TRILHA_CLOUD_API_KEY" \
  --project cadastro-usuarios \
  --workspace-root /var/lib/trilha-runner/workspaces \
  --repo git@github.com:trilha/cadastro-usuarios.git \
  --default-branch main \
  --push \
  --name cadastro-usuarios-worker \
  --once \
  --driver echo

O resultado esperado é TASK-001 → review, uma branch trilha/task-001, um commit e evidências aprovadas para go test ./... e make check. Abra Detalhes, confira os comandos, códigos de saída, branch, commit e log; só então selecione Aprovar.

Evidências da execução em revisão
A revisão mostra a task, o worker, a branch, o commit, cada check e o log antes da aprovação.
Execução concluída no portal
Depois da aprovação, a execução fica concluída e o worker volta a ficar ocioso.

9. Teste login, cadastro por convite e troca de senha#

Inicie a aplicação em uma porta diferente da usada pelo Cloud:

cd cadastro-usuarios
TRILHA_SECRET="$TRILHA_SECRET" \
ADMIN_EMAIL="$ADMIN_EMAIL" \
ADMIN_PASSWORD="$ADMIN_PASSWORD" \
PORT=3100 trilha dev

Abra http://localhost:3100/entrar e entre com admin@example.com e senha-segura-123.

Login da aplicação gerada
O administrador inicial vem de ADMIN_EMAIL e ADMIN_PASSWORD.

Abra http://localhost:3100/admin/usuarios, informe o e-mail e o nome da pessoa e selecione Convidar. A pessoa nasce inativa e sem senha; o administrador recebe um link temporário, não escolhe a senha dela.

Cadastro de usuário por convite
A tela mostra o link de uso único e a pessoa ainda inativa.

Abra o link /convite/{token} em uma janela privada. A pessoa convidada define uma senha com pelo menos 12 caracteres; o token é consumido, a conta fica ativa e o navegador volta para o login.

Definição da primeira senha
A senha nasce com o próprio usuário e o link deixa de valer depois do uso.

Depois de entrar, abra http://localhost:3100/perfil. No card Senha, informe a atual e uma nova senha. A aplicação encerra as sessões e exige novo login com a senha nova.

Troca de senha no perfil
A troca pede a senha atual e encerra a sessão ao concluir.
Confirmação após trocar a senha
O logout após a troca confirma que a nova credencial precisa ser usada.

10. Limites deste exemplo#

O template usa stores em memória para manter o exemplo pequeno. Reiniciar a aplicação apaga usuários, convites, sessões e alterações de senha; o administrador inicial volta a ser criado a partir das variáveis de ambiente. Antes de produção, substitua esses stores por persistência real, configure o envio de e-mail para convites e troca de endereço, use HTTPS e guarde os segredos fora do repositório.

Para repetir o aceite automatizado do próprio Cloud, incluindo navegador headless, rode no repositório trilha-cloud:

make check-all

11. Opere ambientes, segredos, deploy e rollback pela UI#

Depois que o worker estiver conectado, a operação diária não exige terminal:

  1. Em Specs, selecione Executar numa task pronta. O Cloud cria a execução e entrega ao runner um bundle versionado com projeto, repositório, rodada, etapas, critérios e checks.
  2. O runner atualiza um checkout dedicado, materializa .trilha/ usando o Trilha Spec, executa a task num worktree e publica os branches trilha/spec-* e trilha/task-* quando --push está habilitado.
  3. Em Ambientes, crie production, informe a URL HTTPS e o nome do perfil permitido no runner, por exemplo production.
  4. Em Segredos, informe uma variável por linha (NOME=valor). O navegador só envia os valores; o Cloud grava AES-256-GCM e depois mostra apenas os nomes.
  5. Selecione Publicar, informe um commit ou tag imutável e acompanhe o estado. Depois de uma entrega bem-sucedida, Rollback agenda a revisão anterior.

O perfil de entrega fica somente na VPS, em /etc/trilha-runner/delivery.json:

{
  "profiles": {
    "production": {
      "deploy": ["/usr/local/libexec/trilha/deploy-product"],
      "rollback": ["/usr/local/libexec/trilha/rollback-product"],
      "health_url": "https://cadastro-usuarios.eoslab.com.br/health/ready",
      "timeout_seconds": 600
    }
  }
}

O Cloud não envia comandos, chaves Git nem acesso ao socket Docker. O serviço roda como usuário dedicado, sem sudo e com escrita restrita a /var/lib/trilha-runner. O processo de entrega recebe um ambiente mínimo, e qualquer segredo que apareça na saída é mascarado antes do log ser devolvido ao Cloud.

Ambientes e entregas no portal
O ambiente mostra apenas nomes de segredos, revisão atual, perfil permitido e ações auditáveis.
Portal responsivo em 390 pixels
Specs, Kanban, ambientes, execuções e frota continuam operáveis em uma tela móvel.

12. Pause um projeto e deixe o disjuntor puxar o freio#

Cancelar para uma execução. Pausar para um projeto: as execuções na fila continuam na fila, na mesma ordem, os workers seguem enviando heartbeat, e POST /api/runs/next responde 204 para aquele projeto até um humano retomar. Execuções gerenciadas iniciadas em Specs recebem 409 enquanto o projeto está pausado.

  1. Em Projetos, selecione Pausar no projeto. O diálogo pede um motivo (até 240 caracteres) e uma confirmação; o motivo vai para a auditoria e é o que a próxima pessoa lê antes de retomar.
  2. A linha mostra o selo Pausado, o motivo, quem pausou e quando. A página Visão geral do produto mostra o mesmo no painel Freio da frota.
  3. Selecione Retomar e confirme. A fila volta a ser entregue na mesma ordem. Retomar é sempre uma decisão humana; nada retoma um projeto automaticamente.

O mesmo freio, pela API:

curl -sS -X POST "$CLOUD/api/admin/projects/cadastro-usuarios/pause" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"reason":"a spec 004 muda o schema; segurar até a revisão"}'

curl -sS -X POST "$CLOUD/api/admin/projects/cadastro-usuarios/resume" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN"

As duas rotas exigem o token de admin ou uma sessão de operador. Uma chave de worker — mesmo com runs:write — recebe 403: o worker entrega trabalho, não decide se a frota trabalha.

O disjuntor pausa o projeto sozinho quando os números dizem que ele está queimando dinheiro. Na Visão geral do produto, abra Configurar disjuntor e defina os limiares que quiser; zero desativa um limiar:

LimiarDispara quando
max_cost_per_houro custo estimado das tentativas concluídas na última hora passa dele
max_failure_ratenas últimas failure_window execuções concluídas (padrão 10) a fração de falhas passa dele; espera a janela encher
max_repeated_failure_classesse número de tentativas seguidas falhou com o mesmo failure_class
curl -sS -X PUT "$CLOUD/api/admin/projects/cadastro-usuarios/breaker" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"max_cost_per_hour":5,"max_failure_rate":0.5,"failure_window":10,"max_repeated_failure_class":3}'

Ao cruzar um limiar, o projeto é pausado com o motivo breaker:<limiar> e paused_by: circuit breaker; um humano não consegue escrever um motivo que comece com breaker:, então a trilha nunca mente sobre quem puxou o freio. O registro de auditoria project.paused carrega o limite, o valor medido e os sinais daquele momento (cost_last_hour, failure_rate, repeated_failure_class), os mesmos números que o painel Freio da frota e GET /api/admin/products/{name}/metrics mostram. Toda pausa, retomada e mudança de limiar está em GET /api/admin/audit.

13. Evidência além do exit code: evals, atestações e quórum#

Um check que sai com 0 prova que o comando rodou. Não prova que o modelo continua preciso, nem que as pessoas que precisam assinar assinaram. Três coisas fecham essa lacuna.

Evals com limiar. Um registro de evidência com kind: "eval" carrega a métrica em meta. O runner reporta como qualquer outra evidência; o Cloud aplica o gate em POST /api/runs/{id}/result e marca o run como failed quando o valor não atinge o limiar, seja qual for o passed que o worker declarou:

{"kind":"eval","task":"TASK-002","seq":2,"passed":true,
 "meta":{"metric":"triage_top1","value":"0.71","threshold":"0.85","comparator":">="}}

O run termina com failure_class: eval_below_threshold, error_code: EVAL_BELOW_THRESHOLD e a mensagem eval below threshold: triage_top1=0.71 >= 0.85. Comparadores: >= (padrão), >, <=, <, ==.

Uma política de revisão com quórum. Decida quantas atestações, de quais papéis, e se precisam ser assinadas:

curl -sS -X PUT "$CLOUD/api/admin/projects/cadastro-usuarios/review" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"quorum":2,"roles":["uat"],"require_signature":true}'

A partir daí DELETE /api/runs/{id} responde 409 control: review quorum is incomplete: 0 of 2 attestations, missing roles uat até o quórum ser atingido. GET /api/runs/{id}/quorum mostra quem atestou e o que falta.

Atestações assinadas. Cada revisor tem um par de chaves Ed25519; a chave pública é registrada no projeto (POST /api/admin/projects/{name}/attestation-keys com {id, public_key, owner}, base64 da chave de 32 bytes). A assinatura cobre o JSON canônico {"at":…,"by":…,"role":…,"run":…,"statement":…,"task":…} com chaves ordenadas, então uma assinatura nunca serve para outro run:

curl -sS -X POST "$CLOUD/api/runs/$RUN/attest" \
  -H "Authorization: Bearer $WORKER_KEY" -H 'Content-Type: application/json' \
  -d '{"by":"maria","role":"uat","statement":"Aceito em UAT em 2026-09-18",
       "at":"2026-09-18T14:00:00Z","key_id":"uat-maria","signature":"<base64>"}'

Uma atestação sem assinatura só é aceita pela sessão do operador no navegador; uma chave sem assinatura recebe 403 e um registro de auditoria run.attestation_refused. Uma atestação por par (by, role).

Tendência e exportação. O quadro da spec mostra um gráfico por rodada (tarefas concluídas, checks aprovados, evals dentro do limiar, média de cada métrica) renderizado como SVG no servidor; os mesmos números vêm de GET /api/admin/specs/{id}/trend. Para um auditor, GET /api/admin/projects/{name}/evidence?format=csv&spec=<id> baixa as linhas de evidência e atestação exatamente como a tela lista (format=json para máquinas).

Métricas e alertas. Inicie o Cloud com TRILHA_METRICS=/_trilha/metrics e, em produção, TRILHA_OBS_TOKEN_FILE apontando para um segredo de 32+ bytes. O Prometheus coleta trilha_cloud_runs{status}, trilha_cloud_queue_age_seconds{project}, trilha_cloud_breaker_open{project}, trilha_cloud_workers{state}, trilha_cloud_worker_last_seen_seconds{worker,project} e trilha_cloud_runs_awaiting_quorum. deploy/eoslab/compose.observability.yml traz Prometheus e Grafana com regras versionadas: um worker calado por cinco minutos dispara TrilhaWorkerStopped, um disjuntor aberto dispara TrilhaBreakerOpen, uma fila com mais de quinze minutos dispara TrilhaQueueAging.

14. Deixe o GitHub mover a rodada: App, webhooks assinados e o caminho de volta#

Até aqui o Cloud só falava com o GitHub de saída, com um PAT. Uma rodada que começa numa issue e termina com essa issue fechada precisa também do outro sentido, assinado.

Uma GitHub App em vez de PAT. Crie uma App na sua organização com Issues: Read and write, Pull requests: Read and write, Checks: Read e Contents: Read, instale no repositório, baixe a chave privada e entregue os três valores ao projeto. Daí em diante toda chamada ao GitHub usa um token de instalação de vida curta emitido a partir de um JWT RS256; o PAT, se houver, é só o fallback:

curl -sS -X PUT "$CLOUD/api/admin/projects/cadastro-usuarios/github/app" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d "{\"app_id\":12345,\"installation_id\":67890,\"private_key\":$(jq -Rs . < app.pem)}"

A chave fica selada no snapshot e nunca é respondida. A auditoria registra github.app_configured.

Um segredo para o webhook. Escolha um com pelo menos 16 caracteres e registre nos dois lados — no projeto e no webhook do repositório (Settings → Webhooks, content type application/json, eventos Issues, Pull requests, Check runs, Pushes, URL https://<seu-cloud>/api/github/webhook):

curl -sS -X PUT "$CLOUD/api/admin/projects/cadastro-usuarios/github/webhook" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"secret":"'"$(openssl rand -hex 24)"'"}'

Essa rota não tem bearer: cada entrega é autenticada por X-Hub-Signature-256, o HMAC que o GitHub calcula com o segredo, verificado em tempo constante contra o projeto registrado para repository.full_name. Assinatura errada ou repositório desconhecido recebem 401 e auditoria github.webhook_rejected; um X-GitHub-Delivery repetido recebe {"status":"duplicate"} e não faz nada. GET .../github/webhook lista o que chegou e o que cada entrega fez.

O que cada evento faz.

  • issues com a etiqueta ready-for-agent vira uma tarefa ready na rodada ativa (a tarefa já ligada à issue, ou uma nova TASK-nnn no último estágio). Etiquetar de novo não muda nada.
  • pull_request aberto no branch que o runner reportou (trilha/task-002) é ligado àquele run: GET /api/runs/{id} passa a mostrar pull_request com número, URL, estado e head, e o PR recebe um comentário de status por transição, não por evento.
  • check_run concluído vira uma evidência check no run — github:check_run:CI / test, aprovada quando a conclusão é success — sem tocar no veredito. Só o revisor leva um run a done.
  • push registra o último commit no projeto.

O caminho de volta. Quando o revisor fecha o run (DELETE /api/runs/{id}), o Cloud comenta a issue com o resumo das evidências — run, branch@commit, a tabela de checks, evals e atestações —, fecha a issue e comenta o pull request uma última vez. Na auditoria: github.issue_closed, github.pr_commented.

Seus próprios assinantes. O Cloud emite webhooks assinados para task.ready, run.finished, run.closed, pull_request.linked, check.recorded, deployment.finished, project.paused e project.resumed:

curl -sS -X POST "$CLOUD/api/admin/webhooks" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://ci.example.com/trilha","events":["run.closed","check.recorded"],"label":"ci"}'

A resposta traz o segredo uma única vez; verifique X-Webhook-Signature (HMAC de X-Webhook-Timestamp e do corpo) do seu lado. Entregas que falharam são reenviadas com backoff e depois esperam em GET /api/admin/webhooks/deliveries?state=failed; POST /api/admin/webhooks/deliveries/{id}/retry envia os mesmos bytes de novo.

15. Coloque o estado no Postgres: migração, backup e restauração ensaiada#

Até aqui tudo viveu num arquivo JSON reescrito a cada escrita. Serve para uma estação de trabalho; para um control plane cuja evidência é o registro do que um agente fez, está a uma queda de perder o último minuto. O store é a costura: troque o backend e nenhuma rota percebe.

Aponte o Cloud para um banco. Em desenvolvimento a URL pode vir do ambiente; file: abre SQLite, postgres:// abre Postgres. Em produção ela tem que vir de um arquivo com modo 0600; um TRILHA_CLOUD_DATABASE_URL em texto é recusado:

export TRILHA_CLOUD_DATABASE_URL='postgres://trilha:…@localhost:5432/trilha_cloud?sslmode=disable'
make dev

Na subida o Cloud aplica migrations/*.sql sob um advisory lock e grava um recibo com o checksum do arquivo; uma migração editada depois de aplicada impede a subida. Daí em diante cada mutação é uma transação só com as linhas que mudaram, os segredos selados ficam em tabela própria e a auditoria é append-only. /_trilha/health/ready inclui um ping no banco.

Traga o arquivo junto. Pare o Cloud, rode o migrador com o mesmo TRILHA_SECRET (o snapshot guarda valores selados), suba de novo no banco:

go run ./cmd/cloud-migrate -from ./data/cloud.json -to "$TRILHA_CLOUD_DATABASE_URL"
migrated ./data/cloud.json in 13ms: projects=1 products=0 specifications=1 runs=1 workers=0 api_keys=1 environments=0 deployments=0 audit=42

Rode duas vezes e a segunda linha é igual: cada linha é upsert por id. O arquivo nunca é tocado, então o rollback é apontar TRILHA_CLOUD_DATA para ele de novo.

Faça backup e restaure. Em deploy/eoslab, um arquivo secrets/postgres_password basta para o deploy.sh somar o Postgres numa rede interna e um sidecar que grava um pg_dump diário em backups/, guardando TRILHA_CLOUD_BACKUP_KEEP_DAYS deles. Backup que nunca foi restaurado é esperança, não backup, então o runbook registra um ensaio: de um Postgres limpo até o Cloud responder run-000001 a partir do dump, a parte mecânica levou 4,1 segundos, e os objetivos estão escritos como RPO ≤ 24 h e RTO ≤ 15 min de operador. Faça o seu ensaio e anote o seu tempo ao lado.

Esqueça de propósito. Runs antigos e a saída capturada são o grosso do estado. Retenção é decisão de operador, atrás do token admin, com janela de pelo menos uma semana:

curl -sS -X POST "$CLOUD/api/admin/retention" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"older_than_days": 90}'
{"before":"2026-06-20T12:00:00Z","runs_deleted":3,"runs_compacted":1,"output_bytes_dropped":250,"audit_pruned":3,"github_deliveries_dropped":0,"hook_deliveries_dropped":0}

Para tudo que terminou antes da janela: uma tentativa superada por outra mais nova é apagada; o run mais novo de cada tarefa mantém status, veredito, branch, commit e cada registro de evidência com seu digest, perdendo só a saída capturada; logs de entrega e, no Postgres, auditoria antiga vão embora. Uma tarefa que chegou a done nunca perde o registro de como. A auditoria guarda retention.compacted com essas contagens.

16. Dê a cada projeto seu modelo, suas regras e sua conta#

Até aqui os runs de todo projeto usavam uma credencial global e qualquer worker pegava o run mais antigo da fila. Uma frota que serve vários projetos precisa saber, por projeto, qual modelo pode rodar suas tarefas, para onde os dados podem ir, quem pode executar e quanto já custou. Nada disso toca o código; tudo passa pelo store e é auditado.

Registre um provedor com sua região e aponte o projeto para ele. A credencial fica selada em repouso e só viaja dentro do claim que o worker recebe:

curl -sS -X POST "$CLOUD/api/admin/ai/providers" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"id":"gateway-br","kind":"compatible","endpoint":"https://ai.example.com.br/v1","model":"gpt-5","region":"br","credential":"sk-…"}'
curl -sS -X PUT "$CLOUD/api/admin/projects/app/ai" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"provider_id":"gateway-br","driver":"openai-compatible"}'

O driver decide o que o runner faz com isso. openai-compatible entrega base URL, chave e modelo. claude-code entrega a chave do projeto e o runner invoca a CLI do Claude Code com ANTHROPIC_API_KEY (ou CLAUDE_CODE_OAUTH_TOKEN para um provedor com auth_mode oauth), sob a mesma allow-list de ambiente e redação de saída que o runner aplica a todo driver. A chave nunca aparece na saída do run nem na auditoria.

Escreva a política. Uma política diz quais provedores o projeto pode usar, onde seus dados devem ficar, quais labels um worker precisa anunciar e quanto o projeto pode gastar:

curl -sS -X PUT "$CLOUD/api/admin/projects/app/policy" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"data_residency":"br","allowed_providers":["gateway-br"],"required_labels":["region:br"],"cost_ceiling":25}'

A política é verificada duas vezes: ao configurar o provedor e a cada enfileiramento, então mudar qualquer um dos dois depois não deixa um run escapar. Aponte o projeto para um provedor de outra região e as duas respostas são iguais:

HTTP 422  control: policy data_residency: provider "openai-us" is in region "us" (api.openai.com), the project requires "br"

e a auditoria ganha policy.refused com a regra, o provedor e onde foi pego.

Deixe o escalonador escolher. Enfileire com o que importa, um prazo ou uma prioridade:

curl -sS -X POST "$CLOUD/api/runs" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"project":"app","task_id":"TASK-002","deadline":"2026-10-01T00:00:00Z","priority":10}'

Um worker que anuncia --label region:br --capacity 2 faz claim do run com prazo mais próximo entre os que têm as labels que ele tem, depois o de maior prioridade, depois o mais antigo. Um worker de outra região recebe 204, não o run; um worker cujo running iguala a capacidade também recebe 204. Dois workers com labels diferentes servem o mesmo projeto sem nunca tomar o trabalho um do outro.

Leia a conta. O runner reporta modelo, tokens e custo estimado de cada tentativa. O Cloud soma:

curl -sS "$CLOUD/api/admin/cost?project=app" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN"
{"projects":[{"project":"app","org":"acme","runs":1,"attempts":1,"tokens":1200,"cost":0.42,"by_model":{"gpt-5":0.42},"ceiling":25}],
 "organizations":[{"org":"acme","projects":1,"runs":1,"tokens":1200,"cost":0.42}]}

A coluna Custo do painel de projetos mostra o mesmo número. Quando um projeto cruza o cost_ceiling, o circuit breaker do capítulo 12 o pausa com o motivo breaker:cost_ceiling; nada mais é reclamado até um operador retomar. A conta não é um pagamento, é evidência: as mesmas tentativas que o runner anotou, somadas.

17. Promova uma stack multi-serviço: migração antes, uma segunda pessoa, um relatório#

O capítulo 11 fez deploy de um serviço com um perfil. Um produto de verdade é uma stack: uma API, um front web, um worker, Postgres e um gateway de IA num único compose, com uma migração de esquema que precisa rodar antes de o tráfego mudar e nunca pode rodar em produção o que o staging não viu. O Cloud continua sem guardar comando; guarda onde cada ambiente vive, o que ele pode receber, quem precisa concordar e o que cada deploy fez.

Dê ao perfil uma migração e uma verificação de saúde por serviço. No host do runner, em /etc/trilha-runner/delivery.json, um perfil compose tem passos fixos, um bloco migrate e uma URL de saúde por serviço:

{"profiles":{"compose":{
  "steps":[{"name":"pull","argv":["docker","compose","-f","docker-compose.prod.yml","pull"],"on_fail":"abort"},
           {"name":"switch","argv":["docker","compose","-f","docker-compose.prod.yml","up","-d","--wait"],"on_fail":"abort"}],
  "migrate":{"argv":["docker","compose","-f","docker-compose.prod.yml","run","--rm","api","alembic","upgrade","head"],
             "downgrade":["docker","compose","-f","docker-compose.prod.yml","run","--rm","api","alembic","downgrade","-1"],
             "reversible":false},
  "rollback":["/usr/local/libexec/trilha/rollback-compose-image"],
  "health":[{"name":"api","url":"https://acervo.example.com.br/health/ready","timeout_seconds":60},
            {"name":"web","url":"https://acervo.example.com.br/","timeout_seconds":60}],
  "timeout_seconds":900}}}

A migração roda antes do switch. Se falhar, o deploy aborta e a imagem anterior continua servindo. reversible: false diz ao runner que um rollback restaura a imagem anterior e deixa o esquema em paz; marque true só para uma release cujo downgrade foi ensaiado.

Coloque staging e produção numa região, e faça a produção promover o staging. Um ambiente agora carrega region, data_residency, o ambiente de onde promove, se uma segunda pessoa precisa aprovar e uma janela de deploy:

curl -sS -X POST "$CLOUD/api/admin/environments" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"project":"acervo","name":"staging","profile":"compose","region":"br-sp"}'
curl -sS -X POST "$CLOUD/api/admin/environments" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"project":"acervo","name":"prod","url":"https://acervo.example.com.br","profile":"compose","region":"br-sp","data_residency":"br","promotes_from":"staging","require_approval":true,"window":{"days":["mon","tue","wed","thu"],"from":"09:00","to":"18:00","timezone":"America/Sao_Paulo"}}'

Uma região fora da residência de dados do projeto, do capítulo 16, é recusada com 422 e deployment.refused; a mesma regra vale depois em PUT /api/admin/environments/acervo/prod. O card em Ambientes mostra a região, staging · Exige aprovação de uma segunda pessoa, a janela e a versão de cada serviço depois do último deploy bom.

Promova por tag. Faça deploy de v2.3.0 em staging e, quando estiver verde, peça a mesma tag em prod:

curl -sS -X POST "$CLOUD/api/admin/environments/acervo/prod/deployments" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"action":"deploy","revision":"v2.3.0"}'

main é recusado porque é mutável; v2.2.9 é recusado porque não é o que o staging roda; fora da janela a resposta é 422 com a regra window. O que passa fica em pending_approval com quem pediu, e nenhum runner o reclama. Quem pediu não aprova: o token de bootstrap aprovando o próprio pedido recebe 403 e deployment.approval_refused. Outro operador, com a própria sessão, escolhe Aprovar no card ou chama:

curl -sS -X POST "$CLOUD/api/admin/environments/acervo/prod/deployments/$ID/approve" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN"

Só então o deployment fica queued, com approved_by ao lado de requested_by, e a auditoria ganha deployment.approved.

Leia o relatório. O runner reporta o resultado com a versão de cada serviço e as migrações que aplicou, e o Cloud transforma o log dele em relatório:

{"status":"succeeded","revision":"v2.3.0","previous_revision":"v2.2.9",
 "report":{"steps":[{"name":"pull","duration":"12.4s","exit":0},{"name":"migrate","duration":"3.1s","exit":0},{"name":"switch","duration":"41.7s","exit":0}],
           "health":[{"name":"api","status":"200"},{"name":"web","status":"200"}],
           "migration":"applied","migrations":["0007_add_shelf_index"],
           "changes":[{"service":"api","from":"v2.2.9","to":"v2.3.0"},{"service":"web","from":"v2.2.9","to":"v2.3.0"}],
           "duration":"57.2s"}}

Quando a migração falha, migration é failed, failed nomeia o passo e o current_revision do ambiente não se move: a produção fica na tag anterior, marcada degraded. Quando uma verificação de saúde falha depois do switch, o runner volta a imagem e o relatório diz rolled_back: true com not reversible; image-only rollback quando o esquema ficou como estava. Um rollback pedido por um operador continua exigindo motivo e o nome do ambiente digitado de volta, e ignora a janela, a regra de promoção e a aprovação.

Rotacione um segredo sem mostrá-lo. PUT .../secrets com um nome que já existe troca o valor; a auditoria ganha environment.secret_rotated com o nome. O valor aparece num único lugar, o claim que um runner com a chave deployments:write do projeto recebe, e nunca na lista de ambientes, no histórico de deployments, no relatório ou na auditoria.

18. Convide pessoas, dê um papel a cada uma e veja dois repositórios num quadro só#

Até aqui o Cloud tinha um operador: quem segura o token bootstrap ou a senha local de admin. Um programa público tem product owner, revisor jurídico, revisor técnico e alguém do cliente que só acompanha, e seus projetos vivem em repositórios diferentes. Este capítulo coloca uma organização em volta dos projetos, convida pessoas com um papel por escopo, deixa que entrem pelo provedor de identidade da empresa e mostra as tarefas e os marcos de um programa, de vários repositórios, num quadro só.

Crie a organização. Uma organização é um slug e um nome; os projetos apontam para ela pelo org que já carregam:

curl -sS -X POST "$CLOUD/api/admin/organizations" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"slug":"toledo","name":"Prefeitura de Toledo"}'

GET /api/admin/organizations lista cada uma com seus projetos. O mesmo formulário está na página Operadores.

Convide pessoas com um papel por escopo. Um operador é um e-mail e um mapa de escopo para papel. O escopo é * (tudo), org:<slug> (todos os projetos da organização) ou project:<nome>; o papel é viewer, reviewer ou admin. Num projeto vale o mais forte dos três:

curl -sS -X POST "$CLOUD/api/admin/operators" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"email":"ana@toledo.pr.gov.br","name":"Ana","roles":{"org:toledo":"reviewer"}}'
curl -sS -X POST "$CLOUD/api/admin/operators" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"email":"vic@cliente.example","roles":{"project:acervo":"viewer"}}'

Quando TRILHA_MAIL_URL aponta para um servidor SMTP (smtp://user:pass@host:587 ou smtps://…), a pessoa convidada recebe uma mensagem com o link de entrada; sem isso o registro fica e a auditoria diz mail: not_configured. Em desenvolvimento a mensagem vira um arquivo .eml em ./mail. Convidar o mesmo e-mail de novo substitui os papéis; DELETE /api/admin/operators/{email} remove a pessoa, e a próxima requisição dela responde 403 this account is not an operator of the control plane.

O que cada papel pode. A matriz tem uma linha por papel e uma coluna por módulo (projects, specs, runs, keys, environments, secrets, deployments, ai, products, audit, operators, programs, organizations) com três níveis: view < approve < manage. Um GET é view; mover uma tarefa em revisão, atestar uma execução e aprovar um deploy são approve; todo o resto é manage. Uma rota que não nomeia um projeto, como emitir chaves, convidar operadores ou listar todos os projetos, é o escopo global, que só um papel em * alcança. Então Ana, reviewer da organização, move uma tarefa de review para done e aprova um deploy do acervo, e recebe 403 needs manage on keys ao emitir uma chave. Vic lê /api/admin/specs?project=acervo e as páginas desse produto, e recebe 403 em ?project=gateway, na lista sem filtro e em qualquer PATCH.

A matriz é editada em Operadores, na grade do fim da página, ou com PUT /api/admin/policy:

curl -sS -X PUT "$CLOUD/api/admin/policy" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"roles":{"admin":{"*":"manage"},"reviewer":{"*":"view","specs":"approve","runs":"approve","deployments":"approve"},"viewer":{"*":"view"}}}'

Os três nomes de papel são fixos; um módulo ou nível que o código não declara é recusado com 422; e o token bootstrap e TRILHA_CLOUD_ADMIN_EMAIL continuam admin em tudo, então uma matriz errada nunca tranca o último operador fora. Cada recusa é auditada como access.refused com módulo, nível, projeto e papel.

Entre com a identidade da empresa. Ao lado da senha local, o Cloud aceita OpenID Connect do Entra ID, do Keycloak ou de qualquer provedor com discovery. Em deploy/eoslab/.env:

TRILHA_CLOUD_OIDC_PROVIDER=entra
TRILHA_CLOUD_OIDC_TENANT=<id do tenant>
TRILHA_CLOUD_OIDC_CLIENT_ID=<id da aplicação>
TRILHA_CLOUD_OIDC_REDIRECT_URL=https://cloud.example.com.br/login/oidc/callback

com o client secret em deploy/eoslab/secrets/oidc_client_secret (o deploy.sh soma o overlay compose.oidc.yml quando esse arquivo existe; um segredo em variável de ambiente é recusado em produção). Para Keycloak, TRILHA_CLOUD_OIDC_PROVIDER=keycloak, TRILHA_CLOUD_OIDC_URL e TRILHA_CLOUD_OIDC_REALM; para outro provedor, TRILHA_CLOUD_OIDC_ISSUER. A página de login ganha Entrar com OpenID Connect. O provedor prova quem a pessoa é; o Cloud decide se ela pode entrar: um e-mail que não foi convidado é recusado com 401 e auth.login_refused {reason: not_invited}. Quando Ana entra, a auditoria registra auth.login com o sub do provedor como alvo e o e-mail e via: oidc nos campos, e toda mutação dali em diante leva essa identidade como ator. As chaves de runner não mudam com nada disso: uma chave emitida para gateway continua recebendo 403 em acervo e em qualquer rota de /api/admin.

Descreva o programa num arquivo. Um programa agrupa projetos de repositórios diferentes. Escreva program.md no repositório dono dele:

# Catálogo público
org: toledo
projects: acervo, gateway

## MVP — 2026-11-30
- acervo/TASK-001
- gateway/TASK-002

## Piloto — 2027-02-28
- gateway/TASK-003

Um marco é um título ## com a data depois de — ou entre parênteses; cada bullet nomeia uma tarefa como projeto/TASK-ID. Importe (a página Programas tem a mesma caixa):

curl -sS -X POST "$CLOUD/api/admin/programs/cpsi/import" -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d "{\"markdown\":$(jq -Rs . < program.md)}"

Um projeto que não existe, ou uma tarefa fora dos projetos do programa, é recusado com

  1. PUT /api/admin/programs/cpsi edita os mesmos campos em JSON.

Leia o quadro. GET /api/admin/programs/cpsi, ou /programs/cpsi no navegador, lê a rodada mais recente de cada spec dos projetos do programa; a página mostra uma coluna por projeto:

{"program":{"id":"cpsi","org":"toledo","projects":["acervo","gateway"]},
 "tasks":[{"ref":"acervo/TASK-001","project":"acervo","id":"TASK-001","status":"ready","milestone":"MVP"},
          {"ref":"gateway/TASK-002","project":"gateway","id":"TASK-002","status":"ready","milestone":"MVP",
           "depends_on":["acervo/TASK-001"],"blocked_by":["acervo/TASK-001"]}],
 "milestones":[{"title":"MVP","due":"2026-11-30T00:00:00Z","done":0,"total":2,"overdue":false}],
 "next_milestone":{"title":"MVP","due":"2026-11-30T00:00:00Z","done":0,"total":2,"overdue":false},
 "done":0,"total":2,"blocked":1}

O depends_on de uma tarefa em .trilha/tasks/ pode nomear uma tarefa de outro repositório como acervo/TASK-001; enquanto aquela não está done, a dependente aparece no quadro como bloqueada por acervo/TASK-001 e conta em blocked. next_milestone é o marco não concluído mais próximo, marcado overdue depois da data. Ver um programa exige view em programs em pelo menos um dos projetos dele: Vic vê o quadro do cpsi por causa do acervo, e recebe 403 ao importar um.

19. Quando o agente pergunta: a raia Precisa de você, a resposta e a memória da task#

O capítulo do runner mostrou um agente que para e pergunta em vez de chutar. Na sua máquina a resposta foi para a evidência à mão. Com o Cloud, a pergunta chega a uma pessoa, a pessoa responde no portal e o trabalho continua no mesmo branch.

O que cada agente está fazendo. Durante uma execução, o runner informa o estado do agente junto com o progresso. A tabela de execuções e os detalhes da execução mostram um selo: Trabalhando, Parado, Precisa de permissão, Fez uma pergunta ou Erro do provedor. Drivers sem hooks não mostram nenhum.

Uma execução que precisa de você. Um agente que perguntou encerra a execução com status needs_input e a pergunta no resultado. Não é falha: nenhuma tentativa falha é contada, a task continua running e o quórum de revisão não consegue fechá-la. Ela espera na raia Precisa de você do painel, com um contador, ao lado de toda execução cujo agente levantou um motivo de atenção; a tabela de execuções também tem um filtro Precisa de você. A API responde a mesma raia:

curl -sS "$CLOUD/api/runs/needs-you?project=cadastro-usuarios" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY"
[{"id":"run-000007","project":"cadastro-usuarios","task_id":"TASK-002","status":"needs_input",
  "phase":"awaiting_input","agent_state":"asked","branch":"trilha/task-002",
  "result":{"passed":false,"status":"needs_input","branch":"trilha/task-002",
    "question":{"text":"O e-mail de boas-vindas vai só para a pessoa convidada ou também para o administrador que a convidou?","asked_at":"2026-10-26T13:58:02Z"},
    "session":{"driver":"claude-code","session_id":"3f2a9c1e-0000-4000-8000-000000000001"},
    "overview":"E-mail de boas-vindas rascunhado; falta saber quem o recebe.",
    "diff_stats":{"files":3,"insertions":42,"deletions":5}}}]

diff_stats é o tamanho da mudança — arquivos, linhas que entraram, linhas que saíram — nunca o diff.

Responda. Na raia, ou em Detalhes da execução, a caixa Sua resposta ao agente aceita até 8000 caracteres; Enviar resposta continua o trabalho. Pela API:

curl -sS -X POST "$CLOUD/api/runs/run-000007/answer" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"text":"Só para a pessoa convidada. O administrador já vê o convite em /admin/usuarios."}'
{"answered":{"id":"run-000007","status":"superseded","answer":{"by":"…","at":"2026-10-26T14:05:11Z","continuation":"run-000008"},…},
 "continuation":{"id":"run-000008","task_id":"TASK-002","status":"queued","branch":"trilha/task-002",
   "retry_of":"run-000007","repair_reason":"answer to the agent's question",
   "continuation":{"session":{"driver":"claude-code","session_id":"3f2a9c1e-0000-4000-8000-000000000001"},
     "question":"O e-mail de boas-vindas vai só para a pessoa convidada ou também para o administrador que a convidou?",
     "answer":"Só para a pessoa convidada. O administrador já vê o convite em /admin/usuarios.","by":"…"}}}

A resposta é 202. A execução respondida vira superseded (o portal diz Respondida), e uma nova execução da mesma task, na fila, leva a sessão, a pergunta e a resposta ao próximo worker, que retoma a mesma sessão do agente quando consegue. by é quem respondeu: o e-mail do operador pelo portal, key:<nome> por uma chave. Uma execução que não está esperando resposta recebe 409 control: only a needs_input run can be answered. A auditoria registra run.answered com o tamanho e o SHA-256 da resposta, nunca o texto.

Quem pode responder. Uma resposta orienta um agente e gasta o orçamento do projeto, então exige approve em runs na matriz de permissões do capítulo 18 — ou uma chave com runs:write no projeto. Ana, reviewer, responde. Vic, viewer, vê a pergunta e a linha Responder exige o nível aprovar em execuções. no lugar da caixa; o mesmo pedido pela sessão dele recebe 403 needs approve on runs.

Atenção. Um agente pode levantar até cinco motivos curtos de que uma pessoa é necessária agora; eles aparecem na raia em Atenção. Marcar como visto limpa todos, qualquer que seja o status da execução:

curl -sS -X DELETE "$CLOUD/api/runs/run-000009/attention" -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY"

A auditoria ganha run.attention_cleared; seus assinantes de webhook do capítulo 14 podem ouvir run.needs_input e run.attention.

A memória da task. O que uma execução aprende, a próxima lê. O overview e as notas que um agente relata vão para a task — o cartão no quadro Specs mostra o overview e as notas mais novas em Notas (n). Um operador com approve em specs acrescenta notas (até 1200 caracteres) e escreve um overview seu (até 500), que sempre vence o do agente:

curl -sS -X POST "$CLOUD/api/admin/specs/$SPEC/rounds/$ROUND/tasks/TASK-002/notes" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"content":"Convites expiram em 72 horas; o e-mail de boas-vindas precisa dizer isso."}'
curl -sS -X PUT "$CLOUD/api/admin/specs/$SPEC/rounds/$ROUND/tasks/TASK-002/overview" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"overview":"E-mail de boas-vindas só para a pessoa convidada, com a validade do link."}'

Uma task guarda 50 notas, e a mais antiga sai primeiro. A próxima claim leva context com o overview e as cinco notas mais novas, que o runner cita como histórico, nunca como instrução. Toda resposta também vira uma nota de operador — Question: … e depois Answer: … —, então um driver que não consegue retomar uma sessão ainda vê as rodadas anteriores.

20. Workers perdidos, avisos e backups de hora em hora#

Um worker que morre não leva a execução junto. Uma claim agora toma um lease (claimed_at, lease_until na execução), renovado a cada progresso e a cada heartbeat do worker. A cada 30 segundos o Cloud procura execuções cujo lease venceu com o worker offline: cada uma ganha uma tentativa com failure_class: worker_lost e volta para a fila, ou falha com RUN_WORKER_LOST depois da última tentativa. A auditoria registra run.reclaimed e o Prometheus conta trilha_cloud_runs_reclaimed_total. Um worker que volta e manda o mesmo resultado duas vezes — o runner guarda uma caixa de saída exatamente para isso — recebe 200, e o resultado entra uma vez só.

Fique sabendo sem olhar o portal. Avisos não mandam nada até alguém pedir. Cada operador abre Avisos no painel e escolhe eventos × canais:

EventoMensagem
run.needs_inputcadastro-usuarios · TASK-002 (run-000007) needs you
run.failed… failed
run.review… is ready for review
deployment.pending_approval… waits for a deployment approval
project.pausedcadastro-usuarios was paused

Os canais são e-mail, pelo TRILHA_MAIL_URL do capítulo 18, e Web Push: Ativar push neste navegador inscreve o navegador em que você está. O diálogo grava a escolha com PUT /api/notifications ({"events":{"run.needs_input":["email","push"]}}), que só um operador com sessão pode chamar — uma chave não pode. Cada pessoa só fica sabendo dos projetos que pode ver, e uma mensagem nomeia o projeto, a task e o evento com um link — nunca o texto do agente. Defina TRILHA_CLOUD_PUBLIC_URL=https://cloud.example.com para os links saírem absolutos, e TRILHA_CLOUD_VAPID_SUBJECT (um endereço mailto: ou https:) para o contato que os serviços de push veem; o padrão é a URL pública ou o e-mail do admin. O par de chaves do Web Push é gerado no primeiro uso e selado com TRILHA_SECRET.

Um canal do time não é uma pessoa, então webhooks da instância inteira vêm só de um arquivo que quem administra o host escreve, apontado por TRILHA_CLOUD_NOTIFY_FILE. Nenhuma rota escreve nele, e não existe transporte por comando:

{"webhooks":[{"name":"chat-do-time","url":"https://hooks.example.com/trilha",
  "headers":{"Authorization":"Bearer …"},"timeout_seconds":10,
  "events":["run.needs_input","run.failed"],"include_content":false}]}

Sem o arquivo, ou com um arquivo inválido, não existe webhook de instância. Só include_content: true recebe a pergunta do agente; timeout_seconds vai até 30.

Backups de hora em hora do snapshot JSON. Uma instância em TRILHA_CLOUD_DATA (o capítulo 15 levou produção para o Postgres; uma estação de trabalho pode ficar no arquivo) agora grava, ao lado do arquivo, cloud-backups/AAAA-MM-DDTHHZ.json na primeira escrita de cada hora UTC, guardados por 72 horas, e cloud-last-known-good.json, que só avança quando o estado não perdeu mais da metade dos projetos, execuções ou tasks. Quando ele recusa, o log diz que o estado perdeu mais da metade — essa linha é o sinal para olhar antes de as cópias de hora em hora vencerem. Os backups guardam os segredos selados exatamente como o snapshot e nenhuma sessão de navegador. Restaurar é decisão de uma pessoa, com o Cloud parado e o mesmo TRILHA_SECRET:

cp cloud.json "cloud.json.broken-$(date -u +%Y%m%dT%H%M%SZ)"
ls -1 cloud-backups/ | tail -n 5
cp cloud-last-known-good.json cloud.json     # ou uma hora: cp cloud-backups/2026-10-25T13Z.json cloud.json
chmod 600 cloud.json

Depois de restaurar, todo operador entra de novo; resultados de execuções tomadas depois da hora restaurada respondem 409, e a varredura de leases devolve à fila o que ficou running sem worker vivo.

21. Uma segunda opinião: revisão por IA, caçadas de bugs e variantes#

O capítulo do runner mostrou execuções de revisão e de caçada do lado do worker. Aqui está quem as pede e onde os achados chegam. Tudo o que volta é âncora e palavra — um caminho, um intervalo de linhas, um título —, nunca código.

Escreva a política de agentes do projeto. Quem pode editar a política do projeto (manage em projects) decide o que os agentes fazem ali sem uma pessoa pedir toda vez. Por enquanto é só pela API; o portal ainda não tem tela para isso:

curl -sS -X PUT "$CLOUD/api/admin/projects/cadastro-usuarios/agents" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"ai_review":"on_review","review_mode":"report","artifacts":false}'
# {"ai_review":"on_review","review_mode":"report","artifacts":false}

Os padrões — e o que o GET responde para um projeto que ninguém configurou — são off, report e nenhum artefato. review_mode é escolha sua: report deixa o revisor só ler, fix deixa ele fazer commit no branch. Qualquer outro valor é 422; a auditoria registra project.agents_updated.

O que uma revisão por IA faz. Com on_review, uma execução de implementação que chega a review com os checks passando ganha uma execução kind: review no mesmo branch (review_of, review_mode), auditada como run.review_enqueued. A aprovação da pessoa espera por ela. O veredito aparece nos detalhes da execução — Aprovada pela IA ou A IA pediu mudanças —, com os achados, a severidade, Por quê e Como reproduzir. Um veredito changes levanta a atenção "AI review asked for changes" na execução revisada, que então cai em Precisa de você. Não há laço: o resultado de uma revisão nunca enfileira outra revisão. Uma revisão fix que faz commit volta para review para uma pessoa, e a execução revisada vira superseded.

Cace bugs. Numa execução de implementação, os detalhes oferecem Caçar bugs com um número de caçadores. Cada caçador só lê e recebe a próxima semente da task — uma área e um estilo diferentes, para não lerem todos o mesmo código do mesmo jeito:

curl -sS -X POST "$CLOUD/api/runs/hunt" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"project":"cadastro-usuarios","task_id":"TASK-002","count":3}'
[{"id":"run-000011","task_id":"TASK-002","status":"queued","kind":"hunt","review_of":"run-000008",
  "hunt_seed":{"seed":0,"area":"entry points","style":"pessimist"},…},
 {"id":"run-000012","kind":"hunt","hunt_seed":{"seed":1,"area":"error paths","style":"pessimist"},…},
 {"id":"run-000013","kind":"hunt","hunt_seed":{"seed":2,"area":"data layer","style":"pessimist"},…}]

count vai de 1 a 6, e nunca mais de seis caçadas de uma task na fila ou rodando ao mesmo tempo. Quando elas relatam, peça os achados a qualquer uma: o Cloud junta o que todos os caçadores da task acharam — mesmo caminho, linhas que se sobrepõem, mesmo título ignorando maiúsculas e pontuação —, fica com a severidade mais alta e diz quem viu:

curl -sS "$CLOUD/api/runs/run-000011/findings" -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY"
{"run":"run-000011","kind":"hunt","task_id":"TASK-002",
 "findings":[{"severity":"high","path":"app/convite/token_/page.go","line_start":31,"line_end":38,
   "title":"Convite expirado ainda é aceito",
   "why":"o handler compara a validade com a hora da criação, não com agora",
   "repro":"um convite criado há 73 horas ainda abre o formulário de senha",
   "area":"entry points","style":"pessimist",
   "found_by":["run-000011","run-000013"],"runs":2}]}

Para uma execução de revisão, a mesma rota responde os achados dela.

Duas tentativas, fique com a melhor. Rodar em variantes — ou a API — enfileira de duas a quatro tentativas da mesma task lado a lado. O runner trabalha cada uma no seu branch, trilha/task-002-v1, trilha/task-002-v2:

curl -sS -X POST "$CLOUD/api/runs/variants" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"project":"cadastro-usuarios","task_id":"TASK-003","count":2}'
# {"group":"vg-000014","runs":[{"id":"run-000014","variant_group":"vg-000014","variant_index":1,…},{"id":"run-000015",…,"variant_index":2,…}]}
curl -sS "$CLOUD/api/runs/variants/vg-000014" -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY"
{"group":"vg-000014","project":"cadastro-usuarios","task_id":"TASK-003","variants":[
  {"id":"run-000014","variant_index":1,"status":"review","branch":"trilha/task-003-v1","passed":true,
   "checks_passed":2,"checks_failed":0,"diff_stats":{"files":4,"insertions":96,"deletions":12},
   "estimated_cost":0.31,"total_tokens":18200,"duration_ms":212000},
  {"id":"run-000015","variant_index":2,"status":"review","branch":"trilha/task-003-v2","passed":true,
   "checks_passed":2,"checks_failed":0,"diff_stats":{"files":7,"insertions":240,"deletions":30},
   "estimated_cost":0.44,"total_tokens":25900,"duration_ms":305000}]}

A comparação é só por evidência: status, checks, tamanho da mudança, custo. Um grupo de variantes é recusado com 409 enquanto a task tem uma execução de implementação ativa. Escolha uma na tabela com Ficar com esta, ou:

curl -sS -X POST "$CLOUD/api/runs/variants/vg-000014/pick" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"run_id":"run-000014"}'
# {"group":"vg-000014","winner":{"id":"run-000014","variant_outcome":"won",…},"discarded":["run-000015"]}

A vencedora continua em review — e ganha a revisão por IA, se o projeto pediu uma. As outras viram discarded, um desfecho próprio, nunca contado como falha ou cancelamento. Num projeto com GitHub App ou token, o Cloud apaga os branches delas no GitHub, e só nomes no formato trilha/task-nnn-v<N>; sem credencial, só as marca. A auditoria registra variant.picked e uma entrada por branch apagado.

Caçadas, variantes e respostas exigem approve em runs de um operador, ou runs:write no projeto de uma chave.

22. Feche o laço no pull request: comentários de revisão, CI e forks#

Entregue os comentários das pessoas ao agente. Um revisor deixou comentários no pull request; uma execução de reparo pode tratá-los. O Cloud ainda não lê comentários do GitHub, então você os entrega — âncoras e palavras, nunca código:

curl -sS -X POST "$CLOUD/api/runs/run-000014/address-comments" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: application/json' \
  -d '{"comments":[{"id":"c-1","path":"app/convite/token_/page.go","line_start":31,"line_end":38,
       "side":"RIGHT","body":"Compare a validade com time.Now() e acrescente um teste para um convite de 73 horas.",
       "author":"ana"}]}'

A resposta é 201 com uma nova execução da mesma task e branch (retry_of: run-000014) cuja claim leva review_comments. No máximo 50 comentários, cada corpo com até 4000 bytes. Qualquer outro campo é recusado com 400 — o diff_hunk do GitHub acima de tudo, porque é código. O runner cita cada comentário como dado de um revisor, e as respostas do agente aparecem ao lado de cada comentário em Comentários de revisão, nos detalhes da execução. A auditoria conta os comentários, nunca as palavras.

Acompanhe o CI do pull request. Para uma execução com pull request ligado (capítulo 14) e um projeto com GitHub App ou token, o Cloud lê o pull request e os checks a cada dois minutos e os resume numa palavra: falha acima de pendente acima de sucesso. Ele só age depois de duas leituras iguais seguidas, então um check que pisca não faz nada. Os detalhes da execução mostram o selo — CI verde, CI em andamento, CI vermelho — e uma falha levanta a atenção "Pull request CI failed", que não nomeia check nenhum: o nome de um check é escrito por quem controla o branch. Um merge com o head que o worker relatou oferece PR mesclado — concluir a task? com Concluir task, que é a mesma aprovação de DELETE /api/runs/{id}; o Cloud nunca conclui uma task sozinho. Sem credencial, nada é consultado. Assinantes podem ouvir pull_request.ci e pull_request.merged.

Código de forks. Um pull request cujo head vive em outro repositório é ligado com from_fork: true, e toda claim seguinte daquela task leva foreign_code: true. O worker então só a roda no sandbox Docker, não dá segredo nenhum ao agente nem aos checks, lê os checks do branch padrão e nunca publica — o capítulo do runner mostra como isso aparece no worker.

23. Agendas, números e os relatórios do agente#

Automações. Uma task que deve rodar toda segunda, ou uma caçada toda noite, é uma automação: uma RRULE num fuso horário. Como a política de agentes, por enquanto é só pela API:

curl -sS -X POST "$CLOUD/api/admin/projects/cadastro-usuarios/automations" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN" -H 'Content-Type: application/json' \
  -d '{"kind":"hunt","task_id":"TASK-002","hunt_count":2,"rrule":"FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0",
       "timezone":"America/Sao_Paulo","catch_up":"skip","enabled":true}'
{"id":"auto-3f9a1c2b7d4e","kind":"hunt","task_id":"TASK-002","hunt_count":2,
 "rrule":"FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0","timezone":"America/Sao_Paulo",
 "catch_up":"skip","enabled":true,"anchor":"2026-10-26T15:00:00Z",
 "next_run_at":"2026-11-02T12:00:00Z","created_by":"admin","created":"2026-10-26T15:00:00Z"}

A regra fala um subconjunto da RFC 5545: FREQ=HOURLY|DAILY|WEEKLY, INTERVAL, BYDAY, BYHOUR, BYMINUTE; o resto é recusado, nunca adivinhado. O fuso é um nome IANA (UTC por padrão). Uma regra que dispararia mais de uma vez a cada 15 minutos é 422, um projeto tem no máximo 20 automações, e uma automação nunca tem mais de uma execução na fila — uma ocorrência que encontra a última execução ainda na fila fica registrada como busy. catch_up decide o que acontece com as ocorrências que o Cloud perdeu dormindo: skip as registra como perdidas, once enfileira uma única execução por todas elas. Pausa, disjuntor e política valem para execuções automáticas como para qualquer outra. O GET lista as automações com os últimos disparos; PUT …/automations/{id} substitui uma (âncora e histórico ficam); DELETE a remove e mantém as execuções dela. Criar, mudar e remover exige approve em runs e é auditado como automation.*.

Produtividade. A seção de projetos do painel tem um cartão Produtividade (7 dias) por projeto: concluídas ou em revisão, falhas, variantes descartadas, perguntas do agente, mediana até a resposta, revisões por IA (aprovadas/mudanças), achados por severidade e a mediana da fila até a revisão. Os mesmos números, para um dia, uma semana, um mês ou tudo:

curl -sS "$CLOUD/api/admin/projects/cadastro-usuarios/productivity?window=week" \
  -H "Authorization: Bearer $TRILHA_CLOUD_ADMIN_TOKEN"
{"project":"cadastro-usuarios","window":"week","since":"2026-10-19T15:00:00Z",
 "runs":{"done":4,"review":1,"failed":1,"discarded":1,"superseded":2},
 "needs_input":2,"answered":2,"answer_latency_median_seconds":240,
 "review_verdicts":{"pass":3,"changes":1},"findings":{"critical":0,"high":1,"medium":2},
 "queue_to_review_median_seconds":610,"reached_review":5}

Só contagens e durações — nenhum nome de arquivo, nenhuma linha. O Prometheus recebe os últimos sete dias como trilha_cloud_productivity_runs, trilha_cloud_needs_input_runs, trilha_cloud_answer_latency_seconds, trilha_cloud_review_verdicts, trilha_cloud_findings e trilha_cloud_queue_to_review_seconds.

Os relatórios do agente. Um resumo de cobertura ou um screenshot da tela nova ajuda quem aprova. Um projeto só os recebe depois de optar, com "artifacts": true na política de agentes; até lá, um envio recebe 403 com code: ARTIFACTS_DISABLED. O worker envia cada relatório antes do resultado — HTML, Markdown, PNG ou JPEG, até 2 MiB e cinco por execução, com o SHA-256:

# o que o worker envia; você não digita isto
curl -sS -X PUT "$CLOUD/api/runs/run-000016/artifacts/coverage.md" \
  -H "Authorization: Bearer $TRILHA_CLOUD_API_KEY" -H 'Content-Type: text/markdown' \
  -H "X-Artifact-SHA256: $(shasum -a 256 coverage.md | cut -d' ' -f1)" --data-binary @coverage.md
# {"name":"coverage.md","content_type":"text/markdown","size":1834,"sha256":"…","uploaded_at":"…"}

e o resultado só pode listar relatórios enviados com o mesmo nome e o mesmo digest (422 caso contrário). Os detalhes da execução os mostram em Relatórios do agente: imagens direto na página, HTML e Markdown dentro de um <iframe sandbox> sem permissão nenhuma. O Cloud só os serve a uma sessão de operador — uma chave recebe 403 artifacts are read in the portal, with an operator session — com Content-Security-Policy: sandbox; default-src 'none', uma origem opaca que não roda script, não lê cookie e não alcança a rede; Markdown é renderizado sem HTML cru. Os bytes ficam em TRILHA_CLOUD_ARTIFACTS (por padrão cloud-artifacts/ ao lado de TRILHA_CLOUD_DATA) e vão embora com a retenção. Um relatório é algo que uma pessoa lê; o código-fonte continua nunca chegando ao Cloud.

O contrato#

O worker só precisa de três rotas, então outro control plane — o seu — pode implementá-las:

MétodoCaminhoCorpoResposta
POST/api/runs/next{worker, project}200 {id, project, task_id}, ou 204 quando não há nada na fila
POST/api/runs/{id}/result{passed, status, branch, commit, evidence[], log, error}202
POST/api/workers/heartbeat{name, project, status}200

Todas com Authorization: Bearer <chave>. O corpo do resultado é o queue.Result do trilha-runner; evidence[] é o registro do protocolo, sem mudar. O código não viaja. O que os capítulos 19 a 23 acrescentaram no fio — kind, continuation e context na claim; o status needs_input, a pergunta, os achados e os artefatos de relatório no resultado; um lease que o heartbeat renova — é protocolo 0.4 opcional, então essas três rotas continuam bastando.

O que está no MVP e o que não está#

EntregueDepois
Control planeprojetos, specs, rodadas, kanban, execução e revisão; organizações com papéis e escopos; programas que põem vários repositórios num quadro só; agentes que perguntam e operadores que respondem, memória da task, revisão por IA, caçadas de bugs, variantes e automaçõesbilling e planos; telas no portal para a política de agentes e as automações
Frotaworkers persistentes por projeto com labels e capacidade, checkout Git isolado, sincronização .trilha e push opcional, agendamento por prazo e prioridade, leases que tomam de volta a execução de um worker perdidoautoscaling e sandboxes remotos
Entregaambientes com região, residência e janela de deploy, segredos cifrados, perfis locais, promoção de tag imutável, aprovação a quatro olhos antes de produção, relatório de deploy, health check e rollback ciente das migrações; GitHub App e webhooks assinados nos dois sentidos; acompanhamento do CI do pull requestoutros provedores Git; ler comentários de revisão do GitHub
Governançalogin com senha local ou OpenID Connect, CSRF, chaves por projeto com expiração e revogação, auditoria, provedor por projeto, política (provedores, residência de dados, teto de custo, labels exigidas) e livro de custo, gate de eval, atestações assinadas com quórum, exportação de evidência e métricas Prometheus; avisos por e-mail e Web Push; artefatos de relatório servidos de um sandbox de origem opacaMFA, e evidência assinada pelo runner verificada pelo cloud
Armazenamentosnapshot JSON ou Postgres atrás do mesmo store, migrações versionadas, pg_dump diário e restauração ensaiada; backups JSON de hora em hora com uma cópia last-known-goodalta disponibilidade do Postgres

Desafio#

Escreva o menor control plane que um trilha-runner worker --once aceita: um app Trilha com as três rotas acima, uma fila que é um slice em memória, e ainda sem autenticação. Rode o worker contra ele com o driver echo.

Mostrar solução

Três arquivos em app/api/ de um projeto novo (trilha new fila):

// app/api/runs/next/route.go
package next

var fila = []string{"TASK-004"} // os ids de task a entregar, em ordem

func POST(c *trilha.Ctx) error {
	var in struct{ Worker, Project string }
	if err := c.BindJSON(&in); err != nil {
		return err
	}
	if len(fila) == 0 {
		c.Status(204)
		return nil
	}
	id := fila[0]
	fila = fila[1:]
	return c.JSON(200, map[string]string{"id": "run-1", "project": in.Project, "task_id": id})
}
// app/api/runs/id_/result/route.go
package result

func POST(c *trilha.Ctx) error {
	var in map[string]any
	if err := c.BindJSON(&in); err != nil {
		return err
	}
	c.Log().Info("resultado", "run", c.Param("id"), "status", in["status"], "commit", in["commit"])
	c.Status(202)
	return nil
}
// app/api/workers/heartbeat/route.go
package heartbeat

func POST(c *trilha.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) }

trilha gen && trilha dev num terminal; na agenda, trilha-runner worker --cloud http://localhost:3000 --token x --project agenda --once --driver echo. O worker reivindica a TASK-004, roda e posta o resultado que aparece no log. Um slice não é uma fila que dois workers compartilham, e um Authorization que ninguém confere não é um control plane — que é exatamente a lista do que o trilha-cloud acrescenta.