Diagnóstico + fix de dados Rails em produção: kamal exec, `?` largura inteira, Base64, continuidade de thread.
Os 400/500 de produção não se parecem nada com o local. No local você reroda os testes, edita, reroda. Em produção, um usuário já clicou em "Pagar" e está encarando uma tela em branco.
Três caminhos:
rails console, suas mudanças não têm auditoria, um typo e você tá em apurosEsse artigo cobre o terceiro caminho. Dois casos reais: um ? de largura inteira enfiado em credentials que quebrou pagamentos x402 com 500, e um tweet no meio de um thread com status: :failed que precisa ser retransmitido preservando continuidade do thread. No caminho, Claude vai tentar sair por 4 direções erradas por padrão — tem que interceptar cada uma.
kamal app exec --reuseKamal é a ferramenta de deploy Rails da 37signals. Tem um comando:
kamal app exec --reuse 'bin/rails runner "..."'
--reuse significa: não sobe container novo, executa dentro do container web que tá rodando. Sem novo build, sem docker pull, sem restart, sem reinjeção de ENV. Comando roda, container volta a servir requests.
A saída vem pelo stdout até seu terminal — efetivamente um puts dentro de um console Rails de produção, sem SSH, tmux ou sair do laptop.
Sessão típica:
$ kamal app exec --reuse 'bin/rails runner "puts User.count"'
Launching command with version abc123 from existing container...
INFO [ok] Finished in 3.8 seconds with exit status 0
App Host: deploy.how2claude.com
12847
Ida e volta em 3-6 segundos. Duas ordens de grandeza mais rápido que redeploy.
? de largura inteira que causou 500 em produçãoCommit eba9ac9.
Sintoma: Algumas horas depois de subir pagamentos x402, cada request de pagamento tava 500. Logs cheios de Net::HTTPBadResponse do cliente HTTP. Local funcionando perfeito.
Diagnóstico: Peça pro Claude imprimir a config x402 em produção primeiro:
kamal app exec --reuse 'bin/rails runner "puts X402.configuration.wallet_address.inspect"'
Saída:
"0xAbC123...def?"
Tem um ? extra no final — um ponto de interrogação de largura inteira (U+FF1F), não o ? de meia largura. O IME de alguém alternou durante uma edição de config/credentials/production.yml.enc e enfiou esse caractere.
O config/credentials.yml.enc local (decriptado com o master.key de dev/test) não tinha — production e dev são credentials encriptados separados no Rails 8, conteúdo não compartilhado.
Fix: Você não pode fazer SSH e editar o arquivo direto (tá encriptado) nem puxar pro local e editar (a master.key não tá no laptop). A jogada é o Claude escrever um script Ruby descartável e injetar via EDITOR= no credentials:edit:
# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# Remove ponto de interrogação de largura inteira do final
content.gsub!(/(wallet_address: 0x[0-9a-fA-F]+)?\s*$/, '\1')
File.write(ARGV[0], content)
EDITOR="ruby script/fix_prod_wallet.rb" \
bin/rails credentials:edit --environment production
Fluxo de credentials:edit: decriptar → escrever em arquivo temp → invocar $EDITOR → re-encriptar → apagar temp. Trocar EDITOR pelo nosso script Ruby automatiza a edição, sem precisar encarar o cifrado local.
Depois, git commit + kamal deploy uma vez — esse deploy é obrigatório porque production.yml.enc mudou. Mas o diagnóstico não custou um deploy.
Regra: Quando produção quebra, leia primeiro com kamal app exec --reuse. Não chute, não redeploye antes.
Sintoma: Depois que um artigo publica, tweets entram na tabela x_queue_tweets. Um deles — o 2º de um thread de 4 — acaba com status: :failed (rate limit da X API, validação de conteúdo, qualquer motivo). Retransmitir exige preservar continuidade com o 1º tweet do thread.
Achar o falhado:
kamal app exec --reuse 'bin/rails runner "
XQueue::Tweet.where(status: :failed).each do |t|
puts \"#{t.id}: thread=#{t.thread_id} pos=#{t.thread_position} content=#{t.content.inspect}\"
end
"'
Acaba sendo id=87, thread_id=15, thread_position=2.
Armadilha 1: escape de shell. Conteúdo de tweet tem muito aspas, quebra de linha, backtick. Se você escrever:
# explode — shell come aspas e barras invertidas
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'
A dança do Base64 — codifica local, passa a string base64, decodifica dentro do runner:
# Codifica local
echo -n 'Conteúdo reescrito do tweet...' | base64
# => Q29udGXDumRvIHJlZXNjcml0byBkbyB0d2VldC4uLg==
# Passa
kamal app exec --reuse "bin/rails runner \"
t = XQueue::Tweet.find(87)
t.update!(content: Base64.decode64('Q29udGXDumRvIHJlZXNjcml0byBkbyB0d2VldC4uLg=='), status: :scheduled)
puts t.status
\""
Strings Base64 são ASCII puro, seguras pra shell.
Armadilha 2: continuidade do thread. XQueue::PostTweetJob.perform_later(87) publica um tweet solto — não encadeia com o tweet #1 — porque a X API precisa de reply_to_tweet_id, e o Job por padrão não leva.
Achar o x_tweet_id do tweet anterior (envios bem-sucedidos preenchem esse campo):
kamal app exec --reuse "bin/rails runner \"
t = XQueue::Tweet.find(87)
prev = XQueue::Tweet.where(thread_id: t.thread_id, thread_position: t.thread_position - 1).first
puts 'prev x_tweet_id: ' + prev&.x_tweet_id.to_s
\""
# => prev x_tweet_id: 1834567890123456789
Enfileira com o alvo de reply:
kamal app exec --reuse 'bin/rails runner "
XQueue::PostTweetJob.perform_later(87, reply_to_tweet_id: \"1834567890123456789\")
puts \"enqueued\"
"'
O polling_interval do worker é 0.1 segundos — pega o job quase imediatamente. Alguns segundos depois, kamal app exec pra ver o status passar de scheduled pra posted e x_tweet_id preenchido — thread contínuo.
Regra: Operações de dados em produção precisam respeitar restrições de camada de negócio, não só "registro atualizado com sucesso". Continuidade do thread é restrição de negócio; Rails runner não checa pra você.
Nesse tipo de trabalho, o primeiro instinto do Claude costuma estar errado. Pegue cada um:
"Deixa eu fazer SSH e dar uma olhada..."
Redireciona: kamal app exec --reuse bate SSH — no container, env Rails carregado, auditado (os logs do kamal guardam registro), não mexe no shell do host, sem drift de container (reuse garante versão de produção atual).
"Vou escrever uma migration pra tirar o
?de largura inteira do wallet_address..."
Redireciona: Mudar um valor de credentials não precisa de migration (banco não foi tocado). Um Rails runner descartável resolve em 10 segundos; migration precisa de deploy e fica no schema pra sempre. Só use migration se o fix puder repetir; um typo é coisa única.
"Só usar
update!(content: "...")que resolve..."
Redireciona: Qualquer conteúdo gerado por usuário (tweets, comentários, markdown de usuário) deve passar por Base64. Parse de shell de aspas, barra invertida, $ e backtick é campo minado clássico — produção é lugar ruim pra praticar.
perform_later sem parâmetros de negócio"Só retransmitir com
PostTweetJob.perform_later(87)..."
Redireciona: Primeiro pergunte "esse registro se relaciona com outros?" Threads têm relação reply_to, jobs em lote têm batch_id, jobs paginados têm cursor — a lista de argumentos do Job é o carregador dessas relações. Pula um argumento, quebra uma cadeia.
Debugar + mutar dados Rails em produção com Claude — 6 regras:
kamal app exec --reuse 'bin/rails runner "puts X"'. Isole o problema antes de mudar qualquer coisa.kamal app exec --reuse é a ferramenta padrão, não SSH, não redeploy. No container, Rails carregado, 3-6 segundos ida e volta.EDITOR=ruby-script bin/rails credentials:edit --environment production. Script Ruby faz a edição, sem encarar cifrado local.echo -n 'X' | base64 local, Base64.decode64 no runner.Debugar em produção não é transplantar seu fluxo de dev pra produção — você não tem espaço pra iterar nem tolerância a erro. O que você de fato usa é a superfície de introspecção que produção já oferece (Rails runner + kamal exec + credentials:edit), cada passo fazendo a menor mudança possível. Claude pode escrever Ruby correto — mas saber "o que pode ser feito direto vs. o que precisa ser diagnosticado antes" é uma decisão que ele não toma por você. Essa é sua disciplina de produção.