Diagnóstico + arreglo de datos Rails en prod: kamal exec, `?` ancho completo, Base64, continuidad de thread.
Los 400/500 de producción no se parecen en nada al local. En local corres los tests de nuevo, editas, vuelves a correr. En producción, un usuario ya dio clic en "Pagar" y está mirando una pantalla en blanco.
Tres caminos:
rails console, tus cambios no tienen auditoría, un typo y te mete en problemasEste artículo va del tercer camino. Dos casos reales: un ? de ancho completo encajado en los credentials que rompía los pagos x402 con 500, y un tweet en medio de un thread con status: :failed que hay que reintentar preservando la continuidad del thread. Por el camino, Claude intentará salirse por 4 direcciones equivocadas por defecto — hay que interceptar cada una.
kamal app exec --reuseKamal es la herramienta de deploy de Rails de 37signals. Tiene un comando:
kamal app exec --reuse 'bin/rails runner "..."'
--reuse significa: no levantes un contenedor nuevo, ejecuta dentro del contenedor web que ya está corriendo. Sin nuevo build, sin docker pull, sin reinicio, sin reinyección de ENV. Comando corre, contenedor vuelve a atender requests.
La salida sale por stdout hasta tu terminal — efectivamente un puts dentro de una consola Rails de producción, sin SSH, tmux ni salir del portátil.
Sesión 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 y vuelta en 3-6 segundos. Dos órdenes de magnitud más rápido que un redespliegue.
? de ancho completo que provocó los 500Commit eba9ac9.
Síntoma: Unas horas después de lanzar los pagos x402, cada request de pago devolvía 500. Logs llenos de Net::HTTPBadResponse del cliente HTTP. Local funcionaba perfecto.
Diagnóstico: Que Claude imprima primero la config x402 en producción:
kamal app exec --reuse 'bin/rails runner "puts X402.configuration.wallet_address.inspect"'
Salida:
"0xAbC123...def?"
Hay un ? de más al final — un signo de interrogación de ancho completo (U+FF1F), no el ? de ancho medio. El método de entrada de alguien cambió al editar config/credentials/production.yml.enc y coló ese carácter.
El config/credentials.yml.enc local (desencriptado con el master.key de dev/test) no lo tenía — production y dev son credentials encriptadas separadas en Rails 8, contenidos no compartidos.
Fix: No puedes hacer SSH y editar el archivo (está encriptado) ni puedes bajarlo y editarlo local (el master.key no está en tu portátil). La jugada es que Claude escriba un script Ruby desechable e inyectarlo vía EDITOR= en credentials:edit:
# script/fix_prod_wallet.rb
content = File.read(ARGV[0])
# Quita el signo de interrogación de ancho completo del 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
Flujo de credentials:edit: desencriptar → escribir a archivo temporal → invocar $EDITOR → re-encriptar → borrar temporal. Cambiar EDITOR por nuestro script Ruby automatiza la edición, sin necesidad de mirar el cifrado en local.
Después, git commit + kamal deploy una vez — este deploy sí es obligatorio porque production.yml.enc cambió. Pero el diagnóstico no costó un deploy.
Regla: Cuando producción se rompe, lee primero con kamal app exec --reuse. No adivines, no redespliegues primero.
Síntoma: Tras publicar un artículo, los tweets entran a la tabla x_queue_tweets. Uno de ellos — el 2º de un thread de 4 — termina con status: :failed (rate limit de X API, validación de contenido, lo que sea). Reintentarlo requiere preservar la continuidad del thread con el 1º tweet.
Encontrar el fallido:
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
"'
Resulta ser id=87, thread_id=15, thread_position=2.
Trampa 1: escape de shell. El contenido de los tweets suele tener comillas, saltos de línea, backticks. Si escribes:
# revienta — el shell se come comillas y barras invertidas
kamal app exec --reuse 'bin/rails runner "t = XQueue::Tweet.find(87); t.update!(content: \"...\")"'
El baile de Base64 — codifica local, pasa la cadena base64, decodifica dentro del runner:
# Codificar local
echo -n 'Contenido reescrito del tweet...' | base64
# => Q29udGVuaWRvIHJlZXNjcml0byBkZWwgdHdlZXQuLi4=
# Pasarlo
kamal app exec --reuse "bin/rails runner \"
t = XQueue::Tweet.find(87)
t.update!(content: Base64.decode64('Q29udGVuaWRvIHJlZXNjcml0byBkZWwgdHdlZXQuLi4='), status: :scheduled)
puts t.status
\""
Las cadenas Base64 son ASCII solo, seguras para shell.
Trampa 2: continuidad del thread. XQueue::PostTweetJob.perform_later(87) publica un tweet suelto — no encadena con el tweet #1 — porque la X API necesita reply_to_tweet_id y el Job por defecto no lo lleva.
Encontrar el x_tweet_id del tweet anterior (los enviados con éxito llenan este 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
Encolar con el target de reply:
kamal app exec --reuse 'bin/rails runner "
XQueue::PostTweetJob.perform_later(87, reply_to_tweet_id: \"1834567890123456789\")
puts \"enqueued\"
"'
El polling_interval del worker es 0.1 segundos — recoge el job casi de inmediato. Unos segundos después, kamal app exec para ver el status pasar de scheduled a posted y x_tweet_id lleno — thread continuo.
Regla: Las operaciones de datos en producción deben respetar las restricciones a nivel de negocio, no solo "registro actualizado con éxito". La continuidad del thread es restricción de negocio; Rails runner no la chequea por ti.
Al hacer este tipo de trabajo, el primer instinto de Claude suele estar mal. Atrapa cada uno:
"Déjame hacer SSH y echar un vistazo..."
Redirige: kamal app exec --reuse le gana al SSH — en el contenedor, env de Rails cargado, auditado (los logs de kamal dejan registro), no toca el shell del host, sin drift de contenedor (reuse garantiza la versión de producción actual).
"Escribo una migration para eliminar el
?de ancho completo del wallet_address..."
Redirige: Cambiar un valor de credentials no necesita migration (no se tocó la DB). Un Rails runner desechable lo hace en 10 segundos; una migration necesita deploy y vive en el schema para siempre. Solo usa migration si el arreglo podría repetirse; un typo es cosa de una sola vez.
"Con
update!(content: "...")basta..."
Redirige: Cualquier contenido generado por usuario (tweets, comentarios, markdown de usuario) debe pasar por Base64. El parseo del shell de comillas, barras invertidas, $ y backticks es un campo minado clásico — producción no es el sitio para practicarlo.
perform_later sin parámetros de negocio"Relanzo con
PostTweetJob.perform_later(87)y ya..."
Redirige: Primero pregunta "¿este registro se relaciona con otros?" Los threads tienen relaciones de reply_to, los jobs por lotes tienen batch_id, los jobs paginados tienen cursor — la lista de argumentos del Job es el portador de esas relaciones. Omite un argumento y rompes una cadena.
Debuggear + mutar datos de Rails en producción con Claude — 6 reglas:
kamal app exec --reuse 'bin/rails runner "puts X"'. Localiza el problema antes de tocar nada.kamal app exec --reuse es la herramienta por defecto, no SSH ni redespliegue. Dentro del contenedor, Rails cargado, ida y vuelta 3-6 segundos.EDITOR=ruby-script bin/rails credentials:edit --environment production. El script Ruby hace la edición, sin mirar el cifrado en local.echo -n 'X' | base64 en local, Base64.decode64 en el runner.Debuggear en producción no es trasplantar tu flujo de dev — no tienes espacio para iterar ni tolerancia al error. Lo que de verdad usas es la superficie de introspección que producción ya ofrece (Rails runner + kamal exec + credentials:edit), cada paso haciendo el cambio más pequeño posible. Claude puede escribir Ruby correcto — pero saber "qué se puede hacer directo vs. qué hay que diagnosticar primero" es una decisión que no toma por ti. Esa es tu disciplina de producción.