feat(dron): Migración a AtomicDrones con arquitectura DB-First

Implementación del sistema AtomicDrones basado en "Menos es Más":
- Refactorización de dron.rb a dispatcher + módulos atómicos
- Nuevos módulos: ejecutor, vigilante, sanador, bitacora
- DronDB para acceso a datos en PostgreSQL
- Tablas: dron_logs, dron_avances, dron_metricas
- Views: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal
- Health check con detección de zombies (heartbeat > 5min)
- Dashboard compacto de la flota
- Saneamiento con reintentos (backoff exponencial) y limpieza

Comandos soportados:
- dron lanzar: Ejecutar comando como dron
- dron flota: Dashboard de la flota
- dron salud: Health check activo
- dron estado <ID>: Estado detallado
- dron sanear: Saneamiento completo
- dron limpiar: Limpieza de antiguos
- dron reintentar: Re-intentar fallidos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-04-07 11:35:13 -03:00
co-authored by Claude Opus 4.6
parent b5e89144e3
commit 3543e62263
9 changed files with 1968 additions and 485 deletions
+13 -485
View File
@@ -2,508 +2,36 @@
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/dron.rb — Sistema de Drones Aunomos
# adn/tools/cli/dron.rb — Sistema de Drones Atómicos (AtomicDrones)
# ----------------------------------------------------------
# Flota de drones que operan de forma aunoma:
# - Lanzar tareas en background con auto-bitácora
# - Vigilar procesos existentes
# - Orquestar flota: detectar drones caídos, estancados
# - Reportar salud del sistema en tiempo real
# Interfaz CLI unificada para la flota de drones atómicos:
# - ejecutor: lanza comandos en background con auto-bitácora
# - vigilante: monitorea flota, detecta zombies, consolida estados
# - sanador: re-intenta fallidos, limpia antiguos
#
# Principio: Sistema Vivo — los drones operan, reportan
# y se auto-gestionan sin intervención humana.
# Arquitectura: DB-First, módulos atómicos, composición tipo "Menos es Más"
#
# Uso:
# ./adn/tools/run dron lanzar --evento AUTO -- <comando>
# ./adn/tools/run dron flota # Dashboard de la flota
# ./adn/tools/run dron salud # Health check activo
# ./adn/tools/run dron estado # Estado detallado
# ./adn/tools/run dron limpiar # Limpiar completados
# ./adn/tools/run dron estado <ID> # Estado detallado
# ./adn/tools/run dron sanear # Saneamiento completo
# ==========================================================
require 'optparse'
require 'fileutils'
require 'time'
require 'json'
require_relative 'dron/dispatcher'
require_relative '../core/logger'
require_relative '../core/constants'
module ADN
class SubcomandoDron
DRON_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'dron')
ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run')
STALE_MINUTES = 120 # Dron sin actividad > 2h = posible problema
def initialize(args, logger = nil)
@args = args
@logger = logger
FileUtils.mkdir_p(DRON_DIR)
@logger = logger || ADN::Logger.instance
end
def ejecutar
if @args.empty? || @args.first == 'help' || @args.first == '--help'
mostrar_ayuda
return
end
accion = @args.shift
case accion
when 'lanzar'
cmd_lanzar(@args)
when 'vigilar'
cmd_vigilar(@args)
when 'estado'
cmd_estado
when 'flota'
cmd_flota
when 'salud'
cmd_salud
when 'limpiar'
cmd_limpiar
else
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
mostrar_ayuda
exit 1
end
end
private
# ─── lanzar: ejecutar comando en background y vigilar ────────
def cmd_lanzar(args)
evento_id = nil
nota = nil
# Separar opciones del dron del comando a ejecutar
separador = args.index('--')
unless separador
puts "#{Color::RED}✗ Falta separador '--' antes del comando a ejecutar#{Color::RESET}"
puts "Uso: ./adn/tools/run dron lanzar --evento 1379 -- <comando>"
return
end
dron_args = args[0...separador]
comando_args = args[(separador + 1)..-1]
OptionParser.new do |opts|
opts.on("--evento ID", "ID del evento (o AUTO para crear uno)") { |e| evento_id = e }
opts.on("--nota TEXTO", "Nota adicional para el cierre") { |n| nota = n }
end.parse!(dron_args)
# AUTO: crear evento de bitácora automáticamente
if evento_id == 'AUTO'
# Primero generamos el tarea_id para incluirlo en la descripción del evento
tarea_id = "dron_#{Time.now.strftime('%H%M%S')}_#{$$}"
cmd_crear = "#{ADN_RUN} db evento:crear --ambito dtic-BKPs --nodo srv-ns8"
cmd_crear += " --descripcion \"🛸 #{tarea_id}: #{(nota || comando_args.join(' ')).gsub('"', '\\"')}\""
cmd_crear += " --inicio #{Time.now.strftime('%H:%M')} --modo R --ia"
output = `#{cmd_crear} 2>&1`
# El evento recién creado aparece como último en la lista de pendientes
# Output: "⏳ [33mID 1413 [0m 17:12:00 srv-ns8 ..."
all_ids = output.scan(/ID\s+(\d+)/).flatten.map(&:to_i)
if output.include?('Entrada creada exitosamente') || output.include?('✅ Entrada creada')
if all_ids.any?
# Tomamos el ID más alto (el último creado)
evento_id = all_ids.max
@logger&.info("Dron: Evento AUTO ##{evento_id} creado para #{tarea_id}")
else
@logger&.warn("Dron: Evento creado pero no se pudo extraer ID del output")
evento_id = nil
end
else
@logger&.error("Dron: Error al crear evento AUTO: #{output}")
evento_id = nil
end
elsif evento_id
evento_id = evento_id.to_i
end
if comando_args.empty?
puts "#{Color::RED}✗ Falta el comando a ejecutar después de '--'#{Color::RESET}"
return
end
comando_str = comando_args.join(' ')
tarea_id ||= "dron_#{Time.now.strftime('%H%M%S')}_#{$$}"
log_file = File.join(DRON_DIR, "#{tarea_id}.log")
meta_file = File.join(DRON_DIR, "#{tarea_id}.json")
# Guardar metadata
meta = {
tarea_id: tarea_id,
evento_id: evento_id,
comando: comando_str,
nota: nota,
inicio: Time.now.iso8601,
pid: nil,
estado: 'lanzando'
}
File.write(meta_file, JSON.pretty_generate(meta))
# Lanzar el proceso vigía en background
nota_safe = (nota || "").gsub(%q{"}, %q{\"})
cmd_safe = comando_str.gsub(%q{"}, %q{\"})
inicio_epoch = Time.now.to_i
vigia_script = <<~BASH
#!/bin/bash
# Dron vigia — auto-generado
LOGFILE="#{log_file}"
METAFILE="#{meta_file}"
EVENTO_ID="#{evento_id}"
ADN_RUN="#{ADN_RUN}"
TAREA_ID="#{tarea_id}"
INICIO_EPOCH=#{inicio_epoch}
actualizar_evento() {
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --descripcion "$1" >> "$LOGFILE" 2>&1
fi
}
HORA_INICIO=$(date +%H:%M:%S)
DIARIO="Dron: $TAREA_ID
--
$HORA_INICIO - Inicio de vuelo
Mision: #{nota_safe.empty? ? cmd_safe : nota_safe}
Ejecutando: #{cmd_safe}"
echo "[$HORA_INICIO] Dron $TAREA_ID: Inicio de vuelo" >> "$LOGFILE"
actualizar_evento "$DIARIO"
#{comando_str} >> "$LOGFILE" 2>&1
EXIT_CODE=$?
HORA_FIN=$(date +%H:%M:%S)
DURACION=$(( ($(date +%s) - $INICIO_EPOCH) / 60 ))
echo "[$HORA_FIN] Dron $TAREA_ID: Termino con codigo $EXIT_CODE" >> "$LOGFILE"
if [ "$EXIT_CODE" -eq 0 ]; then
DIARIO="$DIARIO
OK $HORA_FIN - Completado (${DURACION}min)
Aterrizaje limpio"
else
DIARIO="$DIARIO
FALLO $HORA_FIN - Codigo $EXIT_CODE (${DURACION}min)
Aterrizaje de emergencia"
fi
ruby -rjson -e '
f = ARGV[0]
m = JSON.parse(File.read(f))
m["fin"] = Time.now.iso8601
m["exit_code"] = ARGV[1].to_i
m["estado"] = ARGV[1].to_i == 0 ? "completado" : "fallido"
File.write(f, JSON.pretty_generate(m))
' "$METAFILE" "$EXIT_CODE"
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
HORA_FIN_HM=$(date +%H:%M)
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN_HM" --descripcion "$DIARIO" >> "$LOGFILE" 2>&1
echo "[$HORA_FIN] Dron $TAREA_ID: Evento $EVENTO_ID cerrado" >> "$LOGFILE"
fi
BASH
vigia_path = File.join(DRON_DIR, "#{tarea_id}.sh")
File.write(vigia_path, vigia_script)
FileUtils.chmod(0755, vigia_path)
pid = spawn("nohup bash #{vigia_path} &", [:out, :err] => '/dev/null')
Process.detach(pid)
# Actualizar meta con PID
meta[:pid] = pid
meta[:estado] = 'vigilando'
File.write(meta_file, JSON.pretty_generate(meta))
puts "#{Color::GREEN}✔ Dron lanzado#{Color::RESET}"
puts " Tarea: #{tarea_id}"
puts " PID: #{pid}"
puts " Evento: #{evento_id || '(sin evento)'}"
puts " Log: #{log_file}"
puts " Comando: #{comando_str}"
puts ""
puts "#{Color::DIM}El dron cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id
puts "#{Color::DIM}Consultar: ./adn/tools/run dron estado#{Color::RESET}"
end
# ─── vigilar: adjuntarse a un PID existente ──────────────────
def cmd_vigilar(args)
evento_id = nil
pid = nil
OptionParser.new do |opts|
opts.on("--evento ID", Integer, "ID del evento") { |e| evento_id = e }
opts.on("--pid PID", Integer, "PID del proceso a vigilar") { |p| pid = p }
end.parse!(args)
unless pid
puts "#{Color::RED}✗ Falta --pid#{Color::RESET}"
return
end
tarea_id = "vigil_#{pid}_#{Time.now.strftime('%H%M%S')}"
log_file = File.join(DRON_DIR, "#{tarea_id}.log")
# Lanzar vigía para PID existente
vigia_cmd = <<~CMD
nohup bash -c '
tail --pid=#{pid} -f /dev/null 2>/dev/null
HORA_FIN=$(date +%%H:%%M)
echo "[$(date)] Dron: PID #{pid} terminó" >> #{log_file}
#{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""}
echo "[$(date)] Dron: Evento #{evento_id} cerrado a las $HORA_FIN" >> #{log_file}
' > /dev/null 2>&1 &
CMD
system(vigia_cmd)
meta = {
tarea_id: tarea_id,
evento_id: evento_id,
pid_vigilado: pid,
inicio: Time.now.iso8601,
estado: 'vigilando'
}
File.write(File.join(DRON_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta))
puts "#{Color::GREEN}✔ Dron vigilando PID #{pid}#{Color::RESET}"
puts " Evento: #{evento_id || '(sin evento)'}"
puts " Log: #{log_file}"
end
# ─── estado: mostrar tareas del dron ───────────────────────
def cmd_estado
metas = Dir.glob(File.join(DRON_DIR, '*.json')).sort
if metas.empty?
puts "#{Color::DIM}Sin tareas de dron activas.#{Color::RESET}"
return
end
puts "#{Color::BOLD}📋 Tareas del Dron#{Color::RESET}\n\n"
metas.each do |f|
m = JSON.parse(File.read(f))
icono = case m['estado']
when 'completado' then "#{Color::GREEN}#{Color::RESET}"
when 'fallido' then "#{Color::RED}#{Color::RESET}"
when 'vigilando' then "#{Color::YELLOW}#{Color::RESET}"
else "#{Color::DIM}?#{Color::RESET}"
end
pid_info = m['pid'] || m['pid_vigilado']
vivo = pid_info && system("kill -0 #{pid_info} 2>/dev/null")
puts " #{icono} #{m['tarea_id']}"
puts " Evento: #{m['evento_id'] || '-'} PID: #{pid_info || '-'}#{vivo ? " #{Color::GREEN}(vivo)#{Color::RESET}" : ''}"
puts " #{m['comando'] || "Vigilando PID #{m['pid_vigilado']}"}"
puts " Inicio: #{m['inicio']}#{m['fin'] ? " Fin: #{m['fin']}" : ''}"
puts ""
end
end
# ─── flota: dashboard compacto de la flota ─────────────────────
def cmd_flota
drones = cargar_todos
if drones.empty?
puts "#{Color::DIM}🛸 Flota vacía — sin drones activos.#{Color::RESET}"
return
end
activos = drones.select { |d| d['estado'] == 'vigilando' }
completados = drones.select { |d| d['estado'] == 'completado' }
fallidos = drones.select { |d| d['estado'] == 'fallido' }
puts "\n#{Color::BOLD}#{Color::CYAN}🛸 Flota de Drones#{Color::RESET}\n"
puts "" * 70
# Activos
if activos.any?
puts "#{Color::YELLOW}⏳ En vuelo (#{activos.length})#{Color::RESET}"
activos.each do |d|
pid = d['pid'] || d['pid_vigilado']
vivo = pid && system("kill -0 #{pid} 2>/dev/null")
elapsed = tiempo_transcurrido(d['inicio'])
comando_corto = (d['comando'] || d['nota'] || '?')[0..55]
estado_pid = vivo ? "#{Color::GREEN}#{Color::RESET}" : "#{Color::RED}#{Color::RESET}"
puts " #{estado_pid} #{d['tarea_id']} #{elapsed.rjust(8)} #{comando_corto}"
end
puts ""
end
# Completados
if completados.any?
puts "#{Color::GREEN}✔ Completados (#{completados.length})#{Color::RESET}"
completados.last(5).each do |d|
duracion = calcular_duracion(d['inicio'], d['fin'])
comando_corto = (d['nota'] || d['comando'] || '?')[0..55]
puts "#{d['tarea_id']} #{duracion.rjust(8)} #{comando_corto}"
end
puts ""
end
# Fallidos
if fallidos.any?
puts "#{Color::RED}✖ Fallidos (#{fallidos.length})#{Color::RESET}"
fallidos.each do |d|
comando_corto = (d['nota'] || d['comando'] || '?')[0..55]
puts "#{d['tarea_id']} exit=#{d['exit_code']} #{comando_corto}"
end
puts ""
end
puts "" * 70
puts "#{Color::BOLD}Total: #{drones.length} drones | " \
"#{Color::YELLOW}#{activos.length}#{Color::RESET} | " \
"#{Color::GREEN}#{completados.length}#{Color::RESET} | " \
"#{Color::RED}#{fallidos.length}#{Color::RESET}"
end
# ─── salud: health check activo ────────────────────────────────
def cmd_salud
drones = cargar_todos
problemas = []
if drones.empty?
puts "#{Color::GREEN}✔ Sin drones — nada que verificar.#{Color::RESET}"
return
end
puts "\n#{Color::BOLD}🏥 Diagnóstico de Salud#{Color::RESET}\n"
puts "" * 70
drones.each do |d|
next unless d['estado'] == 'vigilando'
pid = d['pid'] || d['pid_vigilado']
vivo = pid && system("kill -0 #{pid} 2>/dev/null")
elapsed_min = minutos_transcurridos(d['inicio'])
# Detectar zombie: meta dice vigilando pero PID muerto
if !vivo
problemas << { dron: d['tarea_id'], tipo: '💀 Zombie', detalle: "PID #{pid} muerto pero estado=vigilando", meta: d }
puts " #{Color::RED}💀 ZOMBIE#{Color::RESET} #{d['tarea_id']} — PID #{pid} muerto"
# Auto-reparar: actualizar metadata
meta_path = File.join(DRON_DIR, "#{d['tarea_id']}.json")
if File.exist?(meta_path)
d['estado'] = 'fallido'
d['fin'] = Time.now.iso8601
d['exit_code'] = -1
d['nota_salud'] = 'Auto-detectado como zombie por dron salud'
File.write(meta_path, JSON.pretty_generate(d))
puts " #{Color::DIM}→ Auto-reparado: marcado como fallido#{Color::RESET}"
end
end
# Detectar estancado: lleva mucho tiempo
if vivo && elapsed_min > STALE_MINUTES
problemas << { dron: d['tarea_id'], tipo: '🐌 Estancado', detalle: "#{elapsed_min}min sin finalizar" }
puts " #{Color::YELLOW}🐌 ESTANCADO#{Color::RESET} #{d['tarea_id']}#{elapsed_min}min activo"
# Verificar si el log sigue creciendo
log_path = File.join(DRON_DIR, "#{d['tarea_id']}.log")
if File.exist?(log_path)
log_age = ((Time.now - File.mtime(log_path)) / 60).to_i
if log_age > 30
puts " #{Color::DIM}→ Log sin actualizar hace #{log_age}min#{Color::RESET}"
else
puts " #{Color::DIM}→ Log activo (última escritura hace #{log_age}min)#{Color::RESET}"
end
end
end
# Sano
if vivo && elapsed_min <= STALE_MINUTES
puts " #{Color::GREEN}💚 SANO#{Color::RESET} #{d['tarea_id']}#{elapsed_min}min, PID #{pid} activo"
end
end
# Eventos huérfanos: drones con evento_id pero sin PID vivo
eventos_pendientes = drones.select { |d| d['estado'] == 'fallido' && d['evento_id'] }
if eventos_pendientes.any?
puts "\n#{Color::YELLOW}⚠ Eventos con drones fallidos:#{Color::RESET}"
eventos_pendientes.each do |d|
puts " Evento ##{d['evento_id']}#{d['tarea_id']} (exit=#{d['exit_code']})"
end
end
puts ""
puts "" * 70
if problemas.empty?
puts "#{Color::GREEN}#{Color::BOLD}✔ Flota saludable — sin problemas detectados.#{Color::RESET}"
else
puts "#{Color::RED}#{Color::BOLD}#{problemas.length} problema(s) detectado(s).#{Color::RESET}"
end
end
# ─── limpiar: borrar tareas completadas ──────────────────────
def cmd_limpiar
Dir.glob(File.join(DRON_DIR, '*.json')).each do |f|
m = JSON.parse(File.read(f))
if ['completado', 'fallido'].include?(m['estado'])
base = File.basename(f, '.json')
['.json', '.log', '.sh'].each do |ext|
FileUtils.rm_f(File.join(DRON_DIR, "#{base}#{ext}"))
end
end
end
puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}"
end
# ─── Helpers ─────────────────────────────────────────────────
def cargar_todos
Dir.glob(File.join(DRON_DIR, '*.json')).sort.map do |f|
JSON.parse(File.read(f)) rescue nil
end.compact
end
def tiempo_transcurrido(inicio_str)
return '?' unless inicio_str
mins = ((Time.now - Time.parse(inicio_str)) / 60).to_i
if mins < 60
"#{mins}min"
else
"#{mins / 60}h#{mins % 60}m"
end
end
def calcular_duracion(inicio_str, fin_str)
return '?' unless inicio_str && fin_str
mins = ((Time.parse(fin_str) - Time.parse(inicio_str)) / 60).to_i
if mins < 60
"#{mins}min"
else
"#{mins / 60}h#{mins % 60}m"
end
end
def minutos_transcurridos(inicio_str)
return 0 unless inicio_str
((Time.now - Time.parse(inicio_str)) / 60).to_i
end
def mostrar_ayuda
puts <<~HELP
#{Color::BOLD}#{Color::CYAN}🛸 Dron — Sistema de Drones Autónomos#{Color::RESET}
Flota de drones que operan de forma autónoma: ejecutan tareas
en background, registran en bitácora, detectan problemas y
se auto-gestionan. Sistema vivo, no estático.
#{Color::YELLOW}Comandos:#{Color::RESET}
dron lanzar --evento ID -- <cmd> Lanzar dron de trabajo
dron flota Dashboard compacto de la flota
dron salud Health check (detecta zombies/estancados)
dron estado Estado detallado de cada dron
dron vigilar --evento ID --pid PID Vigilar PID existente
dron limpiar Limpiar completados/fallidos
#{Color::YELLOW}Evento AUTO:#{Color::RESET}
--evento AUTO crea un evento de bitácora automáticamente.
--nota "texto" define la descripción del evento.
#{Color::YELLOW}Ejemplos:#{Color::RESET}
./adn/tools/run dron lanzar --evento AUTO --nota "Backup srvv-sitio" -- ./adn/tools/run bkps backup srvv-sitio
./adn/tools/run dron flota
./adn/tools/run dron salud
HELP
Dron::Dispatcher.ejecutar(@args.dup)
end
end
end
+197
View File
@@ -0,0 +1,197 @@
# frozen_string_literal: true
# Dron::Bitacora — Módulo atómico para registro en bitácora humana
#
# Responsabilidad única: Gestionar registro de eventos en bitácora
# - Crea eventos ⏳ al iniciar dron
# - Actualiza eventos ✅ o ❌ al finalizar
# - Soporta modo AUTO (crea y cierra automáticamente)
#
# Uso:
# require_relative 'dron/bitacora'
# Dron::Bitacora.iniciar(nota: 'Backup SQL')
# Dron::Bitacora.finalizar(evento_id, exit_code: 0)
require_relative '../../db/core/bitacora_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Bitacora
class << self
# Registrar inicio de actividad en bitácora
#
# @param nota [String] Nota descriptiva de la actividad
# @param nodo_id [Integer, nil] ID del nodo (default: 1 = srv-ns8)
# @param modo [String] Modo de ejecución (P=presencial, R=remoto, A=automático)
# @return [Integer, nil] ID del evento creado o nil si falló
def iniciar(nota:, nodo_id: 1, modo: 'A')
@logger = ADN::Logger.new
descripcion = "🛸 dron: #{nota}"
hora_inicio = Time.now.strftime('%H:%M')
@logger.info("📝 Bitácora: Iniciando evento - #{nota}")
sql = <<-SQL
INSERT INTO bitacoras.events (nodo_id, descripcion, estado, modo, inicio)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
SQL
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [nodo_id, descripcion, '⏳', modo, hora_inicio])
evento_id = resultado.first['id']
@logger.info("📝 Evento creado: ##{evento_id}")
evento_id
rescue StandardError => e
@logger.error("📝 Error creando evento: #{e.message}")
nil
end
# Registrar finalización de actividad en bitácora
#
# @param evento_id [Integer] ID del evento a actualizar
# @param exit_code [Integer] Código de salida (0=éxito, >0=fallo)
# @param nota [String, nil] Nota adicional para el resumen
# @return [Boolean] true si se actualizó correctamente
def finalizar(evento_id, exit_code:, nota: nil)
@logger = ADN::Logger.new
estado_icono = exit_code == 0 ? '✅' : '❌'
hora_fin = Time.now.strftime('%H:%M')
# Obtener evento actual para construir descripción
evento = obtener_evento(evento_id)
return false unless evento
# Construir nueva descripción
descripcion_original = evento['descripcion']
nota_adicional = nota ? "#{nota}" : ""
# Extraer nota original si existe
nota_original = descripcion_original.gsub('🛸 dron: ', '').split(' — ').first
descripcion = "🛸 dron: #{nota_original}#{estado_icono}#{nota_adicional}"
@logger.info("📝 Bitácora: Finalizando evento ##{evento_id} - #{estado_icono}")
sql = <<-SQL
UPDATE bitacoras.events
SET descripcion = $1,
estado = $2,
fin = $3,
updated_at = NOW()
WHERE id = $4
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, estado_icono, hora_fin, evento_id])
@logger.info("📝 Evento ##{evento_id} actualizado")
true
rescue StandardError => e
@logger.error("📝 Error actualizando evento: #{e.message}")
false
end
# Registrar inicio y finalización automática (modo AUTO)
# Útil para comandos rápidos que no requieren seguimiento
#
# @param nota [String] Nota descriptiva
# @param bloque [Proc] Bloque de código a ejecutar
# @return [Hash] Resultado de la ejecución
def auto(nota:, &bloque)
@logger = ADN::Logger.new
# Registrar inicio
evento_id = iniciar(nota: nota)
# Ejecutar bloque
inicio = Time.now
resultado = nil
exit_code = 0
begin
resultado = yield
exit_code = 0
rescue StandardError => e
@logger.error("📝 Error en ejecución AUTO: #{e.message}")
exit_code = 1
end
duracion = (Time.now - inicio).round(2)
# Registrar finalización
finalizar(evento_id, exit_code: exit_code, nota: "#{duracion}s") if evento_id
{
evento_id: evento_id,
exit_code: exit_code,
resultado: resultado,
duracion: duracion
}
end
# Actualizar descripción de un evento
#
# @param evento_id [Integer] ID del evento
# @param descripcion [String] Nueva descripción
# @return [Boolean] true si se actualizó correctamente
def actualizar(evento_id, descripcion:)
@logger = ADN::Logger.new
sql = <<-SQL
UPDATE bitacoras.events
SET descripcion = $1, updated_at = NOW()
WHERE id = $2
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, evento_id])
@logger.info("📝 Evento ##{evento_id} actualizado")
true
rescue StandardError => e
@logger.error("📝 Error actualizando evento: #{e.message}")
false
end
# Obtener información de un evento
#
# @param evento_id [Integer] ID del evento
# @return [Hash, nil] Datos del evento o nil si no existe
def obtener_evento(evento_id)
sql = 'SELECT * FROM bitacoras.events WHERE id = $1'
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [evento_id])
resultado.first
end
# Vincular un evento con un flujo de drones
#
# @param evento_id [Integer] ID del evento
# @param flujo_id [String] ID del flujo
# @return [Boolean] true si se vinculó correctamente
def vincular_flujo(evento_id, flujo_id)
@logger = ADN::Logger.new
evento = obtener_evento(evento_id)
return false unless evento
metadata = evento['metadata'] || '{}'
metadata_hash = JSON.parse(metadata) rescue {}
metadata_hash['flujo_id'] = flujo_id
sql = <<-SQL
UPDATE bitacoras.events
SET metadata = $1
WHERE id = $2
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [metadata_hash.to_json, evento_id])
@logger.info("📝 Evento ##{evento_id} vinculado al flujo #{flujo_id}")
true
rescue StandardError => e
@logger.error("📝 Error vinculando flujo: #{e.message}")
false
end
end
end
end
+278
View File
@@ -0,0 +1,278 @@
# frozen_string_literal: true
# Dron::Dispatcher — Dispatcher de subcomandos para AtomicDrones
#
# Responsabilidad única: Despachar comandos a los módulos atómicos correctos
# - Parsea argumentos de línea de comandos
# - Invoca el módulo atómico correspondiente
# - Muestra ayuda formateada
#
# Uso:
# require_relative 'dron/dispatcher'
# Dron::Dispatcher.ejecutar(ARGV)
require_relative '../../core/help_formatter'
require_relative '../../core/logger'
require_relative '../../core/colores'
module Dron
class Dispatcher
def self.ejecutar(args)
@logger ||= ADN::Logger.new
subcomando = args.shift
case subcomando
when 'lanzar', 'run', nil
EjecutorComando.ejecutar(args)
when 'flota', 'list'
VigilanteComando.flota(args)
when 'salud', 'health'
VigilanteComando.salud(args)
when 'sanear', 'clean'
SanadorComando.sanear(args)
when 'limpiar', 'cleanup'
SanadorComando.limpiar(args)
when 'reintentar', 'retry'
SanadorComando.reintentar(args)
when 'estado', 'status'
VigilanteComando.estado(args)
when 'ayuda', 'help', '--help', '-h'
mostrar_ayuda
else
@logger.error("❌ Subcomando desconocido: #{subcomando}")
mostrar_ayuda
exit 1
end
end
def self.mostrar_ayuda
lines = []
lines << "#{Color::BOLD}🛸 Sistema de Drones ADN (AtomicDrones)#{Color::RESET}"
lines << "#{Color::DIM}#{'=' * 50}#{Color::RESET}"
lines << ""
lines << "#{Color::CYAN}Gestión de drones atómicos especializados para automatización de tareas#{Color::RESET}"
lines << ""
lines << "Uso: #{Color::YELLOW}./adn/tools/run dron <subcomando> [opciones]#{Color::RESET}"
lines << ""
lines << "\n#{Color::BOLD}Subcomandos:#{Color::RESET}"
lines << " #{Color::GREEN}lanzar [OPTIONS]#{Color::RESET} Lanzar tarea como dron ejecutor"
lines << " #{Color::GREEN}flota#{Color::RESET} Dashboard compacto de la flota"
lines << " #{Color::GREEN}salud#{Color::RESET} Health check activo (detecta zombies)"
lines << " #{Color::GREEN}estado <ID>#{Color::RESET} Estado detallado de un dron"
lines << " #{Color::GREEN}sanear#{Color::RESET} Sanear flota (reintentar + limpiar)"
lines << " #{Color::GREEN}limpiar#{Color::RESET} Limpiar drones antiguos"
lines << " #{Color::GREEN}reintentar#{Color::RESET} Re-intentar drones fallidos"
lines << ""
lines << "\n#{Color::BOLD}Opciones:#{Color::RESET}"
lines << " #{Color::GREEN}--nota \"texto\"#{Color::RESET} Nota descriptiva para la bitácora"
lines << " #{Color::GREEN}--evento ID#{Color::RESET} Vincular a evento existente"
lines << " #{Color::GREEN}--timeout N#{Color::RESET} Timeout en segundos (default: 3600)"
lines << " #{Color::GREEN}--dry-run#{Color::RESET} Simulación sin ejecutar"
lines << " #{Color::GREEN}--help#{Color::RESET} Mostrar esta ayuda"
lines << ""
lines << "\n#{Color::BOLD}Ejemplos:#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron lanzar --nota 'Backup SQL' -- vzdump 103#{Color::RESET}"
lines << " #{Color::CYAN}Lanzar backup como dron#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron flota#{Color::RESET}"
lines << " #{Color::CYAN}Ver estado de la flota#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron salud#{Color::RESET}"
lines << " #{Color::CYAN}Health check de la flota#{Color::RESET}"
lines << ""
puts lines.join("\n")
end
end
# ============================================================================
# Comandos encapsulados para cada módulo atómico
# ============================================================================
class EjecutorComando
def self.ejecutar(args)
require_relative 'ejecutor'
# Parsear argumentos
opciones = parsear_opciones(args)
if opciones[:cmd].nil?
puts "❌ Error: Debes especificar un comando a ejecutar"
puts ""
puts "Uso: ./adn/tools/run dron lanzar --nota \"Nota\" -- <comando>"
exit 1
end
# Lanzar dron ejecutor
resultado = Dron::Ejecutor.lanzar(
cmd: opciones[:cmd],
nota: opciones[:nota],
evento_id: opciones[:evento_id],
timeout: opciones[:timeout],
flujo_id: opciones[:flujo_id]
)
# Mostrar resultado
puts "\n🛸 Resultado del Dron Ejecutor"
puts "" * 60
puts " Dron ID: #{resultado[:dron_id]}"
puts " Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}"
puts " Exit Code: #{resultado[:exit_code]}"
puts " Duración: #{resultado[:duration]}s"
puts "" * 60
exit resultado[:exit_code]
end
def self.parsear_opciones(args)
opciones = {
nota: nil,
evento_id: nil,
timeout: 3600,
flujo_id: nil,
cmd: nil
}
# Parsear opciones con nombre
while args.first&.start_with?('--')
arg = args.shift
case arg
when '--nota'
opciones[:nota] = args.shift
when '--evento'
opciones[:evento_id] = args.shift.to_i
when '--timeout'
opciones[:timeout] = args.shift.to_i
when '--flujo'
opciones[:flujo_id] = args.shift
when '--help', '-h'
Dispatcher.mostrar_ayuda
exit 0
end
end
# El resto es el comando a ejecutar
opciones[:cmd] = args.join(' ') unless args.empty?
opciones
end
end
class VigilanteComando
def self.flota(args)
require_relative 'vigilante'
output = Dron::Vigilante.dashboard
puts output
end
def self.salud(args)
require_relative 'vigilante'
diagnosticos = Dron::Vigilante.salud
exit(diagnosticos[:saludable] ? 0 : 1)
end
def self.estado(args)
require_relative 'vigilante'
require_relative '../../db/core/dron_db'
dron_id = args.first
if dron_id.nil?
puts "❌ Error: Debes especificar un dron_id"
puts "Uso: ./adn/tools/run dron estado <dron_id>"
exit 1
end
dron = ADN::DB::DronDB.obtener_dron(dron_id)
if dron.nil?
puts "❌ Dron no encontrado: #{dron_id}"
exit 1
end
puts "\n📊 Estado del Dron"
puts "" * 60
puts " ID: #{dron['dron_id']}"
puts " Tipo: #{dron['tipo']}"
puts " Estado: #{dron['estado']}"
puts " Comando: #{dron['cmd']}"
puts " Inicio: #{dron['started_at']}"
puts " Fin: #{dron['completed_at'] || '—'}"
puts " Heartbeat: #{dron['heartbeat']}"
puts " Reintentos: #{dron['reintentos']}"
puts " Exit Code: #{dron['exit_code'] || '—'}"
puts "" * 60
# Mostrar avances si existen
require_relative '../../db/core/bitacora_db'
sql = 'SELECT * FROM bitacoras.dron_avances WHERE dron_id = $1 ORDER BY paso ASC'
avances = BitacorasDB::BitacoraDB.ejecutar(sql, [dron_id])
unless avances.empty?
puts "\n📝 Avances:"
avances.each do |avance|
puts " [#{avance['estado']}] Paso #{avance['paso']}: #{avance['descripcion']}"
end
end
end
end
class SanadorComando
def self.sanear(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.sanear(
dias_limpieza: opciones[:dias],
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.limpiar(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.limpiar_antiguos(
dias: opciones[:dias],
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.reintentar(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.reintentar_fallidos(
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.parsear_opciones(args)
opciones = {
dias: 7,
dry_run: false
}
args.each do |arg|
case arg
when '--dry-run'
opciones[:dry_run] = true
when '--dias'
idx = args.index(arg)
opciones[:dias] = args[idx + 1].to_i if args[idx + 1]
end
end
opciones
end
end
end
+233
View File
@@ -0,0 +1,233 @@
# frozen_string_literal: true
# Dron::Ejecutor — Módulo atómico para ejecución de comandos
#
# Responsabilidad única: Ejecutar un comando y reportar resultado
# - Lanza el comando en background
# - Registra inicio/fin en dron_logs (DB)
# - Actualiza heartbeat cada 30s
# - Captura output y exit_code
#
# Uso:
# require_relative 'dron/ejecutor'
# Dron::Ejecutor.lanzar(cmd: 'vzdump 103', nota: 'Backup SQL')
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Ejecutor
# Timeout por defecto para comandos (en segundos)
DEFAULT_TIMEOUT = 3600 # 1 hora
# Intervalo de heartbeat (en segundos)
HEARTBEAT_INTERVAL = 30
class << self
# Lanzar un comando como dron ejecutor
#
# @param cmd [String] Comando a ejecutar
# @param nota [String, nil] Nota descriptiva para bitácora
# @param evento_id [Integer, nil] ID de evento en bitácora (si existe)
# @param timeout [Integer] Timeout en segundos (default: 3600)
# @param flujo_id [String, nil] ID de orquestación (si es parte de un flujo)
# @return [Hash] Resultado de la ejecución
def lanzar(cmd:, nota: nil, evento_id: nil, timeout: DEFAULT_TIMEOUT, flujo_id: nil)
@logger = ADN::Logger.new
# Generar ID único para este dron
dron_id = ADN::DB::DronDB.generar_dron_id
# Registrar inicio en DB (dron_logs)
ADN::DB::DronDB.registrar_inicio(
tipo: 'ejecutor',
cmd: cmd,
flujo_id: flujo_id,
metadata: { nota: nota, evento_id: evento_id, timeout: timeout }
)
@logger.info("🛠️ Dron Ejecutor iniciado: #{dron_id}")
@logger.info(" Comando: #{cmd}")
@logger.info(" Timeout: #{timeout}s") if timeout
# Registrar en bitácora humana (evento ⏳)
evento_id = registrar_inicio_bitacora(nota, cmd, evento_id) if nota
# Ejecutar comando en background
resultado = ejecutar_comando(cmd, dron_id, timeout)
# Registrar fin en DB
ADN::DB::DronDB.registrar_fin(dron_id, resultado[:exit_code], resultado[:output])
# Registrar en bitácora humana (evento ✅ o ❌)
registrar_fin_bitacora(evento_id, resultado, nota) if evento_id
@logger.info("🛠️ Dron Ejecutor completado: #{dron_id}")
@logger.info(" Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}")
@logger.info(" Duración: #{resultado[:duration]}s")
{
dron_id: dron_id,
exit_code: resultado[:exit_code],
output: resultado[:output],
duration: resultado[:duration],
evento_id: evento_id
}
rescue StandardError => e
# Registrar fallo crítico
ADN::DB::DronDB.registrar_fin(dron_id, 1, "Error: #{e.message}") if dron_id
registrar_fin_bitacora(evento_id, { exit_code: 1, output: e.message, duration: 0 }, nota) if evento_id
@logger.error("🛠️ Dron Ejecutor falló: #{dron_id}")
@logger.error(" Error: #{e.message}")
{
dron_id: dron_id,
exit_code: 1,
output: e.message,
duration: 0,
evento_id: evento_id,
error: e.message
}
end
private
# Ejecutar comando y capturar output
#
# @param cmd [String] Comando a ejecutar
# @param dron_id [String] ID del dron
# @param timeout [Integer] Timeout en segundos
# @return [Hash] Resultado con exit_code, output, duration
def ejecutar_comando(cmd, dron_id, timeout)
start_time = Time.now
output = []
exit_code = nil
# Thread para actualizar heartbeat
heartbeat_thread = Thread.new do
loop do
sleep(HEARTBEAT_INTERVAL)
ADN::DB::DronDB.actualizar_heartbeat(dron_id)
@logger.debug("💓 Heartbeat: #{dron_id}")
end
end
begin
# Ejecutar comando con timeout
Timeout.timeout(timeout) do
# Usar Open3 para capturar output en tiempo real
require 'open3'
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
# Leer stdout y stderr concurrentemente
while stdout.readline || stderr.readline
begin
line = stdout.readline.chomp
output << line if line && !line.empty?
@logger.debug(" #{line}") if line && !line.empty?
rescue EOFError
begin
line = stderr.readline.chomp
output << "[ERR] #{line}" if line && !line.empty?
@logger.debug(" [ERR] #{line}") if line && !line.empty?
rescue EOFError
break
end
end
end
exit_code = wait_thr.value.exitstatus
end
end
rescue Timeout::Error
exit_code = 124 # Código estándar para timeout
output << "\n[TIMEOUT] Comando excedió #{timeout}s de ejecución"
@logger.error(" [TIMEOUT] Comando excedió #{timeout}s")
rescue StandardError => e
exit_code = 1
output << "\n[ERROR] #{e.message}"
@logger.error(" [ERROR] #{e.message}")
ensure
# Detener thread de heartbeat
heartbeat_thread.exit
end
duration = (Time.now - start_time).round(2)
{
exit_code: exit_code,
output: output.join("\n"),
duration: duration
}
end
# Registrar inicio en bitácora humana (evento ⏳)
#
# @param nota [String] Nota descriptiva
# @param cmd [String] Comando ejecutado
# @param evento_id [Integer, nil] ID de evento existente
# @return [Integer] ID del evento creado/actualizado
def registrar_inicio_bitacora(nota, cmd, evento_id = nil)
require_relative '../../db/core/bitacora_db'
descripcion = "🛸 dron: #{nota}"
if evento_id
# Actualizar evento existente
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion)
evento_id
else
# Crear nuevo evento
BitacorasDB::BitacoraDB.crear_evento(
nodo_id: 1, # srv-ns8 por defecto (mejora: hacer configurable)
descripcion: descripcion,
inicio: Time.now.strftime('%H:%M'),
estado: '⏳'
)
end
rescue StandardError => e
@logger.warn("⚠️ No se pudo registrar en bitácora: #{e.message}")
nil
end
# Registrar fin en bitácora humana (evento ✅ o ❌)
#
# @param evento_id [Integer] ID del evento
# @param resultado [Hash] Resultado de la ejecución
# @param nota [String] Nota descriptiva
def registrar_fin_bitacora(evento_id, resultado, nota)
require_relative '../../db/core/bitacora_db'
return unless evento_id
estado = resultado[:exit_code] == 0 ? '✅' : '❌'
duracion = formato_duracion(resultado[:duration])
descripcion = "🛸 dron: #{nota}#{estado} (#{duracion})"
BitacorasDB::BitacoraDB.actualizar_evento(
evento_id,
descripcion: descripcion,
fin: Time.now.strftime('%H:%M')
)
rescue StandardError => e
@logger.warn("⚠️ No se pudo actualizar bitácora: #{e.message}")
end
# Formatear duración en segundos a formato legible
#
# @param segundos [Numeric] Duración en segundos
# @return [String] Duración formateada (ej: "2min 30s")
def formato_duracion(segundos)
minutos = (segundos / 60).to_i
secs = (segundos % 60).to_i
if minutos > 0
"#{minutos}min #{secs}s"
else
"#{secs}s"
end
end
end
end
end
+207
View File
@@ -0,0 +1,207 @@
# frozen_string_literal: true
# Dron::Sanador — Módulo atómico para reparación de drones
#
# Responsabilidad única: Reparar drones zombies/fallidos
# - Re-intenta drones fallidos (máx 3 intentos, backoff exponencial)
# - Limpia drones completados antiguos
# - Notifica fallos persistentes
#
# Uso:
# require_relative 'dron/sanador'
# Dron::Sanador.reintentar_fallidos
# Dron::Sanador.limpiar_completados
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Sanador
# Máximo de reintentos antes de marcar como fallo persistente
MAX_REINTENTOS = 3
# Backoff exponencial: 1min, 2min, 4min entre reintentos
BACKOFF_BASE = 60 # segundos
# Días de antigüedad para limpiar completados
CLEANUP_DAYS = 7
class << self
# Re-intentar drones fallidos (máx 3 intentos con backoff)
#
# @param dry_run [Boolean] Si true, solo muestra qué haría
# @return [Hash] Resultado de la operación
def reintentar_fallidos(dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...")
# Obtener drones fallidos que puedan reintentarse
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'failed'
AND reintentos < #{MAX_REINTENTOS}
AND cmd IS NOT NULL
ORDER BY completed_at ASC
SQL
require_relative '../../db/core/bitacora_db'
fallidos = BitacorasDB::BitacoraDB.ejecutar(sql)
if fallidos.empty?
@logger.info("🩹 No hay drones fallidos para re-intentar")
return { reintentados: 0, omitidos: 0 }
end
reintentados = 0
omitidos = 0
fallidos.each do |dron|
reintentos = dron['reintentos'].to_i
# Calcular tiempo de espera con backoff exponencial
espera = BACKOFF_BASE * (2 ** reintentos) # 60s, 120s, 240s
# Verificar si ya pasó el tiempo de backoff
tiempo_desde_fallo = Time.now - dron['completed_at']
if tiempo_desde_fallo < espera
@logger.debug("#{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)")
omitidos += 1
next
end
if dry_run
@logger.info("🩹 [DRY RUN] Re-intentaría: #{dron['dron_id']}")
reintentados += 1
else
# Re-intentar el comando
@logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{reintentos + 1}/#{MAX_REINTENTOS})")
# Incrementar contador de reintentos
nuevos_reintentos = ADN::DB::DronDB.incrementar_reintentos(dron['dron_id'])
# Volver a lanzar el comando
require_relative 'ejecutor'
resultado = Dron::Ejecutor.lanzar(
cmd: dron['cmd'],
nota: "Reintento #{nuevos_reintentos + 1}: #{dron.dig('metadata', 'nota')}",
flujo_id: dron['flujo_id']
)
if resultado[:exit_code] == 0
@logger.info("🩹 ✅ Reintento exitoso: #{dron['dron_id']}")
reintentados += 1
else
@logger.warn("🩹 ❌ Reintento fallido: #{dron['dron_id']}")
# Si llegó al máximo de reintentos, notificar
if nuevos_reintentos >= MAX_REINTENTOS
notificar_fallo_persistente(dron)
end
end
end
end
puts "\n🩹 Resultado de reintentos:"
puts " Reintentados: #{reintentados}"
puts " Omitidos (backoff): #{omitidos}"
{ reintentados: reintentados, omitidos: omitidos }
end
# Limpiar drones completados/failed/zombies antiguos
#
# @param dias [Integer] Días de antigüedad para limpiar
# @param dry_run [Boolean] Si true, solo muestra qué limpiaría
# @return [Hash] Resultado de la operación
def limpiar_antiguos(dias: CLEANUP_DAYS, dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...")
sql = <<-SQL
SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs
WHERE estado IN ('completed', 'failed', 'zombie')
AND completed_at < NOW() - INTERVAL '#{dias} days'
SQL
require_relative '../../db/core/bitacora_db'
resultado = BitacorasDB::BitacoraDB.ejecutar(sql)
cantidad = resultado.first['cantidad'].to_i
if cantidad == 0
@logger.info("🩹 No hay drones antiguos para limpiar")
return { eliminados: 0 }
end
if dry_run
@logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos")
puts "\n🩹 Drones a limpiar: #{cantidad}"
return { eliminados: 0 }
end
# Eliminar drones antiguos
sql_delete = <<-SQL
DELETE FROM bitacoras.dron_logs
WHERE estado IN ('completed', 'failed', 'zombie')
AND completed_at < NOW() - INTERVAL '#{dias} days'
SQL
BitacorasDB::BitacoraDB.ejecutar(sql_delete)
@logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos")
puts "\n🩹 Drones eliminados: #{cantidad}"
{ eliminados: cantidad }
end
# Sanear toda la flota (reintentar + limpiar)
#
# @param dias_limpieza [Integer] Días para limpieza
# @param dry_run [Boolean] Si true, modo simulación
# @return [Hash] Resultado combinado
def sanear(dias_limpieza: CLEANUP_DAYS, dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Iniciando saneamiento completo...")
resultados = {
reintentos: reintentar_fallidos(dry_run: dry_run),
limpieza: limpiar_antiguos(dias: dias_limpieza, dry_run: dry_run)
}
puts "\n🩹 Saneamiento completado"
puts " Reintentos: #{resultados[:reintentos][:reintentados]}"
puts " Limpieza: #{resultados[:limpieza][:eliminados]}"
resultados
end
# Notificar fallo persistente (tras 3 reintentos fallidos)
#
# @param dron [Hash] Datos del dron con fallo persistente
def notificar_fallo_persistente(dron)
@logger = ADN::Logger.new
@logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']}")
@logger.error(" Comando: #{dron['cmd']}")
@logger.error(" Reintentos: #{dron['reintentos']}")
# Registrar en dron_avances como incidente crítico
sql = <<-SQL
INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado)
VALUES ($1, -99, $2, 'critical')
RETURNING *
SQL
descripcion = "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos: #{dron['cmd']&.slice(0, 100)}"
BitacorasDB::BitacoraDB.ejecutar(sql, [dron['dron_id'], descripcion])
# TODO: Integrar con dron_mensajero para notificar por Slack/email
# Dron::Mensajero.notificar(
# tipo: 'critical',
# mensaje: "Dron #{dron['dron_id']} falló persistentemente tras #{dron['reintentos']} reintentos",
# contexto: dron
# )
end
end
end
end
+286
View File
@@ -0,0 +1,286 @@
# frozen_string_literal: true
# Dron::Vigilante — Módulo atómico para monitoreo de flota
#
# Responsabilidad única: Escanear flota y detectar anomalías
# - Lee dron_logs para obtener estado de la flota
# - Detecta zombies (heartbeat > 5min)
# - Registra avances en dron_avances
# - Consolida resumen en events (visible en Bitácora Web)
# - Alerta sobre anomalías
#
# Uso:
# require_relative 'dron/vigilante'
# Dron::Vigilante.escanear_flota
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Vigilante
# Tiempo máximo sin heartbeat antes de considerar zombie (5 minutos)
ZOMBIE_TIMEOUT = 300
# Umbral para alerta de anomalías (>5 fallos en 1 hora)
ANOMALY_THRESHOLD = 5
class << self
# Escanear toda la flota y reportar estado
#
# @return [Hash] Resumen de la flota
def escanear_flota
@logger = ADN::Logger.new
@logger.info("👁️ Dron Vigilante: Escaneando flota...")
resumen = ADN::DB::DronDB.resumen_flota
# Mostrar resumen
puts "\n🛸 Flota de Drones"
puts "" * 60
puts " Total: #{resumen[:total]}"
puts " 🟢 Running: #{resumen[:running] || 0}"
puts " ⏳ Pending: #{resumen[:pending] || 0}"
puts " ✅ Completed: #{resumen[:completed] || 0}"
puts " ❌ Failed: #{resumen[:failed] || 0}"
puts " 🧟 Zombies: #{resumen[:zombie] || 0}"
puts "" * 60
{
exitoso: true,
resumen: resumen,
timestamp: Time.now
}
rescue StandardError => e
@logger.error("👁️ Error escaneando flota: #{e.message}")
{
exitoso: false,
error: e.message,
timestamp: Time.now
}
end
# Detectar drones zombies (heartbeat > 5min)
#
# @return [Array<Hash>] Lista de drones zombies detectados
def detectar_zombies
@logger = ADN::Logger.new
zombies = ADN::DB::DronDB.detectar_zombies
if zombies.empty?
@logger.info("👁️ No hay drones zombies")
return []
end
@logger.warn("👁️ Detectados #{zombies.count} drones zombies:")
zombies.each do |zombie|
minutos = zombie['minutos_sin_heartbeat'].to_f.round(1)
@logger.warn(" 🧟 #{zombie['dron_id']} - #{minutos}min sin heartbeat")
@logger.warn(" Tipo: #{zombie['tipo']}")
@logger.warn(" Cmd: #{zombie['cmd']&.slice(0, 50)}...")
# Marcar como zombie en DB
ADN::DB::DronDB.marcar_zombie(zombie['dron_id'])
# Registrar avance del incidente
registrar_avance_incidente(zombie['dron_id'], "Dron detectado como zombie (#{minutos}min sin heartbeat)")
end
zombies
end
# Verificar salud de la flota (health check activo)
#
# @return [Hash] Diagnóstico de salud
def salud
@logger = ADN::Logger.new
@logger.info("👁️ Dron Vigilante: Health check...")
diagnosticos = {
zombies: [],
fallos_recientes: [],
anomalias: [],
saludable: true
}
# 1. Detectar zombies
zombies = detectar_zombies
diagnosticos[:zombies] = zombies.map { |z| z['dron_id'] }
# 2. Verificar fallos recientes (últimos 60 min)
fallos_recientes = verificar_fallos_recientes
diagnosticos[:fallos_recientes] = fallos_recientes
# 3. Detectar anomalías (>5 fallos en 1h)
if fallos_recientes.count >= ANOMALY_THRESHOLD
diagnosticos[:anomalias] << "Demasiados fallos en la última hora: #{fallos_recientes.count}"
diagnosticos[:saludable] = false
@logger.error("🚨 ANOMALÍA DETECTADA: #{diagnosticos[:anomalias].first}")
end
# 4. Verificar drones running muy antiguos (>1 hora)
running_antiguos = verificar_running_antiguos
unless running_antiguos.empty?
diagnosticos[:anomalias] << "Drones running demasiado antiguos: #{running_antiguos.count}"
diagnosticos[:saludable] = false
end
# Resumen
puts "\n🏥 Salud de la Flota"
puts "" * 60
puts " Estado: #{diagnosticos[:saludable] ? '✅ Saludable' : '🚨 Problemas detectados'}"
puts " Zombies: #{diagnosticos[:zombies].count}"
puts " Fallos recientes: #{diagnosticos[:fallos_recientes].count}"
puts " Anomalías: #{diagnosticos[:anomalias].count}"
unless diagnosticos[:anomalias].empty?
puts "\n Detalle de anomalías:"
diagnosticos[:anomalias].each { |a| puts " ⚠️ #{a}" }
end
puts "" * 60
diagnosticos
end
# Consolidar estado de un flujo y actualizar evento resumen
#
# @param flujo_id [String] ID del flujo a consolidar
# @return [String, nil] Estado consolidado
def consolidar_flujo(flujo_id)
@logger = ADN::Logger.new
estado = ADN::DB::DronDB.consolidar_estado_flujo(flujo_id)
return nil unless estado
drones = ADN::DB::DronDB.obtener_por_flujo(flujo_id)
count = drones.count
# Actualizar evento resumen en bitácora (visible en web)
actualizar_evento_resumen(flujo_id, estado, count)
@logger.info("👁️ Flujo #{flujo_id} consolidado: #{estado} (#{count} drones)")
estado
end
# Obtener dashboard de la flota (versión compacta)
#
# @return [String] Dashboard formateado
def dashboard
resumen = ADN::DB::DronDB.resumen_flota
activos = ADN::DB::DronDB.obtener_activos
output = []
output << "\n🛸 Dashboard de Drones"
output << "" * 70
output << " Total: #{resumen[:total]} | 🟢 #{resumen[:running] || 0} | ⏳ #{resumen[:pending] || 0} | ✅ #{resumen[:completed] || 0} | ❌ #{resumen[:failed] || 0} | 🧟 #{resumen[:zombie] || 0}"
output << "" * 70
unless activos.empty?
output << "\n Activos:"
output << " #{"ID".ljust(25)} | #{"Tipo".ljust(15)} | #{"Inicio".ljust(10)} | Cmd"
output << " " + "" * 70
activos.each do |dron|
id_corto = dron['dron_id'].slice(0, 24)
tipo = dron['tipo'].to_s.slice(0, 14)
inicio = dron['started_at'].strftime('%H:%M:%S')
cmd = dron['cmd']&.slice(0, 30) || ''
output << " #{id_corto.ljust(25)} | #{tipo.ljust(15)} | #{inicio.ljust(10)} | #{cmd}"
end
end
output.join("\n")
end
private
# Verificar fallos en la última hora
#
# @return [Array<Hash>] Lista de drones fallidos recientemente
def verificar_fallos_recientes
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'failed'
AND completed_at > NOW() - INTERVAL '60 minutes'
ORDER BY completed_at DESC
SQL
require_relative '../../db/core/bitacora_db'
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Verificar drones running demasiado antiguos (>1 hora)
#
# @return [Array<Hash>] Lista de drones antiguos
def verificar_running_antiguos
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'running'
AND started_at < NOW() - INTERVAL '1 hour'
ORDER BY started_at ASC
SQL
require_relative '../../db/core/bitacora_db'
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Registrar avance de incidente en dron_avances
#
# @param dron_id [String] ID del dron
# @param descripcion [String] Descripción del incidente
def registrar_avance_incidente(dron_id, descripcion)
require_relative '../../db/core/bitacora_db'
sql = <<-SQL
INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado)
VALUES ($1, -1, $2, 'failed')
RETURNING *
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id, descripcion]).to_a }
end
# Actualizar evento resumen en bitácora (visible en web)
#
# @param flujo_id [String] ID del flujo
# @param estado [String] Estado consolidado
# @param count [Integer] Cantidad de drones
def actualizar_evento_resumen(flujo_id, estado, count)
require_relative '../../db/core/bitacora_db'
estado_icono = case estado
when 'completed' then '✅'
when 'failed' then '❌'
else '⏳'
end
descripcion = "🛸 Flujo #{flujo_id}: #{count} drones - #{estado_icono}"
# Buscar evento existente por flujo_id en metadata
sql = <<-SQL
SELECT id FROM bitacoras.events
WHERE metadata->>'flujo_id' = $1
SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]).to_a }
if resultado.empty?
# Crear nuevo evento resumen
BitacorasDB::BitacoraDB.crear_evento(
nodo_id: 1,
descripcion: descripcion,
estado: estado_icono,
metadata: { flujo_id: flujo_id, drones_count: count }.to_json
)
else
# Actualizar evento existente
evento_id = resultado.first['id']
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion, estado: estado_icono)
end
rescue StandardError => e
@logger.warn("⚠️ No se pudo actualizar evento resumen: #{e.message}")
end
end
end
end
+346
View File
@@ -0,0 +1,346 @@
# frozen_string_literal: true
require_relative 'bitacora_db'
require 'securerandom'
module ADN
module DB
# DronDB - Acceso a datos para el sistema de AtomicDrones
# Gestiona registro y consulta de drones en tablas internas
class DronDB
# Estados posibles de un dron
ESTADOS = {
pending: 'pending',
running: 'running',
completed: 'completed',
failed: 'failed',
zombie: 'zombie'
}.freeze
# Tipos de drones
TIPOS = {
ejecutor: 'ejecutor',
vigilante: 'vigilante',
sanador: 'sanador',
planificador: 'planificador',
mensajero: 'mensajero',
orquestador: 'orquestador'
}.freeze
# Tiempo máximo sin heartbeat antes de considerar zombie (5 minutos)
ZOMBIE_TIMEOUT = 300
class << self
# Generar ID único para un dron
# Formato: dron_HHMMSS_PID_RANDOM
def generar_dron_id
timestamp = Time.now.strftime('%H%M%S')
pid = Process.pid
random = SecureRandom.hex(3)
"dron_#{timestamp}_#{pid}_#{random}"
end
# Registrar inicio de un dron
# @param tipo [String] Tipo de dron (ejecutor, vigilante, etc.)
# @param cmd [String] Comando a ejecutar
# @param flujo_id [String, nil] ID de orquestación (si aplica)
# @param metadata [Hash] Metadata adicional
# @return [Hash] Datos del dron registrado
def registrar_inicio(tipo:, cmd:, flujo_id: nil, metadata: {})
sql = <<-SQL
INSERT INTO bitacoras.dron_logs (dron_id, tipo, estado, cmd, flujo_id, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
SQL
dron_id = generar_dron_id
params = [dron_id, tipo, ESTADOS[:running], cmd, flujo_id, metadata.to_json]
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, params) }
# Actualizar heartbeat inicial
actualizar_heartbeat(dron_id)
{
dron_id: dron_id,
estado: ESTADOS[:running],
started_at: resultado['started_at'],
mensaje: "Dron #{dron_id} registrado como #{tipo}"
}
end
# Registrar finalización de un dron
# @param dron_id [String] ID del dron
# @param exit_code [Integer] Código de salida
# @param output [String] Salida del comando
# @return [Hash] Resultado de la operación
def registrar_fin(dron_id, exit_code, output = nil)
estado = exit_code == 0 ? ESTADOS[:completed] : ESTADOS[:failed]
sql = <<-SQL
UPDATE bitacoras.dron_logs
SET estado = $1,
exit_code = $2,
output = $3,
completed_at = NOW(),
updated_at = NOW()
WHERE dron_id = $4
RETURNING *
SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado, exit_code, output, dron_id]) }
# Actualizar métricas diarias
actualizar_metricas(dron_id, estado)
{
dron_id: dron_id,
estado: estado,
exit_code: exit_code,
completed_at: resultado['completed_at']
}
end
# Actualizar heartbeat de un dron
# @param dron_id [String] ID del dron
def actualizar_heartbeat(dron_id)
sql = <<-SQL
UPDATE bitacoras.dron_logs
SET heartbeat = NOW(), updated_at = NOW()
WHERE dron_id = $1
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }
end
# Obtener información de un dron
# @param dron_id [String] ID del dron
# @return [Hash, nil] Datos del dron o nil si no existe
def obtener_dron(dron_id)
sql = 'SELECT * FROM bitacoras.dron_logs WHERE dron_id = $1'
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }
resultado.first
end
# Obtener drones por estado
# @param estado [String] Estado a filtrar
# @return [Array<Hash>] Lista de drones
def obtener_por_estado(estado)
sql = 'SELECT * FROM bitacoras.dron_logs WHERE estado = $1 ORDER BY started_at DESC'
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado]) }
end
# Obtener todos los drones activos (running o pending)
# @return [Array<Hash>] Lista de drones activos
def obtener_activos
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado IN ('running', 'pending')
ORDER BY started_at DESC
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Detectar drones zombies (heartbeat > 5min)
# @return [Array<Hash>] Lista de drones zombies
def detectar_zombies
sql = <<-SQL
SELECT * FROM bitacoras.vw_dron_zombies
ORDER BY heartbeat ASC
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Marcar dron como zombie
# @param dron_id [String] ID del dron
def marcar_zombie(dron_id)
sql = <<-SQL
UPDATE bitacoras.dron_logs
SET estado = $1, updated_at = NOW()
WHERE dron_id = $2
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [ESTADOS[:zombie], dron_id]) }
end
# Incrementar contador de reintentos
# @param dron_id [String] ID del dron
# @return [Integer] Nuevo contador de reintentos
def incrementar_reintentos(dron_id)
sql = <<-SQL
UPDATE bitacoras.dron_logs
SET reintentos = reintentos + 1, updated_at = NOW()
WHERE dron_id = $1
RETURNING reintentos
SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }
resultado.first['reintentos'].to_i
end
# Registrar avance de un paso en flujo multi-paso
# @param dron_id [String] ID del dron
# @param paso [Integer] Número de paso
# @param descripcion [String] Descripción del paso
# @param estado [String] Estado del paso
# @param output [String, nil] Salida del paso
# @param flujo_id [String, nil] ID de orquestación
def registrar_avance(dron_id, paso:, descripcion:, estado:, output: nil, flujo_id: nil)
sql = <<-SQL
INSERT INTO bitacoras.dron_avances (dron_id, flujo_id, paso, descripcion, estado, output)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id, flujo_id, paso, descripcion, estado, output]) }
end
# Obtener avances de un dron
# @param dron_id [String] ID del dron
# @return [Array<Hash>] Lista de avances
def obtener_avances(dron_id)
sql = <<-SQL
SELECT * FROM bitacoras.dron_avances
WHERE dron_id = $1
ORDER BY paso ASC
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) }
end
# Obtener avances de un flujo
# @param flujo_id [String] ID del flujo
# @return [Array<Hash>] Lista de avances
def obtener_avances_flujo(flujo_id)
sql = <<-SQL
SELECT * FROM bitacoras.dron_avances
WHERE flujo_id = $1
ORDER BY paso ASC
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]) }
end
# Obtener métricas por fecha y tipo
# @param fecha [Date] Fecha de las métricas
# @param tipo [String] Tipo de dron
# @return [Hash, nil] Métricas o nil si no existen
def obtener_metricas(fecha:, tipo:)
sql = 'SELECT * FROM bitacoras.dron_metricas WHERE fecha = $1 AND dron_tipo = $2'
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo]) }
resultado.first
end
# Actualizar métricas diarias (llamado automáticamente al finalizar un dron)
# @param dron_id [String] ID del dron
# @param estado [String] Estado final del dron
def actualizar_metricas(dron_id, estado)
dron = obtener_dron(dron_id)
return unless dron
fecha = dron['started_at'].to_date
tipo = dron['tipo']
# Calcular duración
started = dron['started_at']
completed = dron['completed_at'] || Time.now
duracion = completed - started
# Upsert de métricas
sql = <<-SQL
INSERT INTO bitacoras.dron_metricas (fecha, dron_tipo, total_ejecuciones, completados, fallidos, zombies, tiempo_promedio, reintentos_total)
VALUES ($1, $2, 1, $3, $4, $5, $6, $7)
ON CONFLICT (fecha, dron_tipo) DO UPDATE SET
total_ejecuciones = bitacoras.dron_metricas.total_ejecuciones + 1,
completados = bitacoras.dron_metricas.completados + $3,
fallidos = bitacoras.dron_metricas.fallidos + $4,
zombies = bitacoras.dron_metricas.zombies + $5,
tiempo_promedio = (
(bitacoras.dron_metricas.tiempo_promedio * bitacoras.dron_metricas.total_ejecuciones + $6) /
(bitacoras.dron_metricas.total_ejecuciones + 1)
),
reintentos_total = bitacoras.dron_metricas.reintentos_total + $7,
updated_at = NOW()
SQL
es_completado = estado == ESTADOS[:completed] ? 1 : 0
es_fallido = estado == ESTADOS[:failed] ? 1 : 0
es_zombie = estado == ESTADOS[:zombie] ? 1 : 0
reintentos = dron['reintentos'].to_i
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo, es_completado, es_fallido, es_zombie, duracion, reintentos]) }
end
# Obtener resumen de estado de la flota
# @return [Hash] Resumen de la flota
def resumen_flota
sql = <<-SQL
SELECT
estado,
COUNT(*) as cantidad
FROM bitacoras.dron_logs
GROUP BY estado
SQL
resultados = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
resumen = { total: 0 }
resultados.each do |fila|
resumen[fila['estado'].to_sym] = fila['cantidad'].to_i
resumen[:total] += fila['cantidad'].to_i
end
resumen
end
# Obtener drones por flujo
# @param flujo_id [String] ID del flujo
# @return [Array<Hash>] Lista de drones del flujo
def obtener_por_flujo(flujo_id)
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE flujo_id = $1
ORDER BY started_at ASC
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]) }
end
# Consolidar estado de un flujo
# @param flujo_id [String] ID del flujo
# @return [String] Estado consolidado (running, completed, failed)
def consolidar_estado_flujo(flujo_id)
drones = obtener_por_flujo(flujo_id)
return nil if drones.empty?
# Si todos completados → completed
# Si alguno failed → failed
# Si no → running
if drones.all? { |d| d['estado'] == ESTADOS[:completed] }
ESTADOS[:completed]
elsif drones.any? { |d| d['estado'] == ESTADOS[:failed] }
ESTADOS[:failed]
else
ESTADOS[:running]
end
end
# Limpiar drones antiguos (más de N días)
# @param dias [Integer] Días de antigüedad
# @return [Integer] Cantidad de drones eliminados
def limpiar_antiguos(dias: 7)
sql = <<-SQL
DELETE FROM bitacoras.dron_logs
WHERE estado IN ('completed', 'failed', 'zombie')
AND completed_at < NOW() - INTERVAL '#{dias} days'
SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
resultado.first['count'].to_i
end
end
end
end
end
@@ -0,0 +1,231 @@
# frozen_string_literal: true
# Migration: 003 - Tablas para sistema de Drones (AtomicDrones)
#
# Usage:
# ruby adn/tools/db/migrations/003_create_dron_tables.rb
#
# Propósito: Crear tablas internas para registro detallado de drones
# - dron_logs: Logs detallados de cada dron
# - dron_avances: Avances paso-a-paso de flujos multi-paso
# - dron_metricas: Métricas agregadas diarias por tipo de dron
require_relative '../core/bitacora_db'
puts "🔧 Running migration 003: Create dron tables"
BitacorasDB::BitacoraDB.with_connection do |db|
# ============================================================================
# Tabla 1: dron_logs
# ============================================================================
puts "\n📝 Creando tabla bitacoras.dron_logs..."
db.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS bitacoras.dron_logs (
id SERIAL PRIMARY KEY,
dron_id VARCHAR(50) NOT NULL,
flujo_id VARCHAR(50),
tipo VARCHAR(20) NOT NULL,
estado VARCHAR(20) NOT NULL,
cmd TEXT,
output TEXT,
exit_code INTEGER,
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
heartbeat TIMESTAMP DEFAULT NOW(),
reintentos INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
SQL
puts "✅ Tabla bitacoras.dron_logs creada"
# Índices para dron_logs
indices_dron_logs = [
["idx_dron_logs_estado", "CREATE INDEX IF NOT EXISTS idx_dron_logs_estado ON bitacoras.dron_logs(estado)"],
["idx_dron_logs_heartbeat", "CREATE INDEX IF NOT EXISTS idx_dron_logs_heartbeat ON bitacoras.dron_logs(heartbeat)"],
["idx_dron_logs_flujo", "CREATE INDEX IF NOT EXISTS idx_dron_logs_flujo ON bitacoras.dron_logs(flujo_id)"],
["idx_dron_logs_tipo", "CREATE INDEX IF NOT EXISTS idx_dron_logs_tipo ON bitacoras.dron_logs(tipo)"],
["idx_dron_logs_started_at", "CREATE INDEX IF NOT EXISTS idx_dron_logs_started_at ON bitacoras.dron_logs(started_at)"]
]
indices_dron_logs.each do |nombre, sql|
db.execute(sql)
puts "📈 Índice creado: #{nombre}"
end
# ============================================================================
# Tabla 2: dron_avances
# ============================================================================
puts "\n📝 Creando tabla bitacoras.dron_avances..."
db.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS bitacoras.dron_avances (
id SERIAL PRIMARY KEY,
dron_id VARCHAR(50) NOT NULL,
flujo_id VARCHAR(50),
paso INTEGER NOT NULL,
descripcion TEXT NOT NULL,
estado VARCHAR(20) NOT NULL,
output TEXT,
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
)
SQL
puts "✅ Tabla bitacoras.dron_avances creada"
# Índices para dron_avances
indices_dron_avances = [
["idx_dron_avances_dron", "CREATE INDEX IF NOT EXISTS idx_dron_avances_dron ON bitacoras.dron_avances(dron_id)"],
["idx_dron_avances_flujo", "CREATE INDEX IF NOT EXISTS idx_dron_avances_flujo ON bitacoras.dron_avances(flujo_id)"],
["idx_dron_avances_paso", "CREATE INDEX IF NOT EXISTS idx_dron_avances_paso ON bitacoras.dron_avances(paso)"]
]
indices_dron_avances.each do |nombre, sql|
db.execute(sql)
puts "📈 Índice creado: #{nombre}"
end
# ============================================================================
# Tabla 3: dron_metricas
# ============================================================================
puts "\n📝 Creando tabla bitacoras.dron_metricas..."
db.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS bitacoras.dron_metricas (
id SERIAL PRIMARY KEY,
fecha DATE NOT NULL,
dron_tipo VARCHAR(20) NOT NULL,
total_ejecuciones INTEGER DEFAULT 0,
completados INTEGER DEFAULT 0,
fallidos INTEGER DEFAULT 0,
zombies INTEGER DEFAULT 0,
tiempo_promedio INTERVAL DEFAULT '0 seconds',
reintentos_total INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(fecha, dron_tipo)
)
SQL
puts "✅ Tabla bitacoras.dron_metricas creada"
# Índices para dron_metricas
db.execute("CREATE INDEX IF NOT EXISTS idx_dron_metricas_fecha ON bitacoras.dron_metricas(fecha)")
puts "📈 Índice creado: idx_dron_metricas_fecha"
# ============================================================================
# Trigger para actualizar updated_at
# ============================================================================
puts "\n📝 Creando trigger para actualizar updated_at..."
db.execute(<<~SQL)
CREATE OR REPLACE FUNCTION bitacoras.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql
SQL
puts "✅ Función update_updated_at_column creada"
# Triggers para cada tabla
triggers = [
["update_dron_logs_updated_at", "dron_logs"],
["update_dron_metricas_updated_at", "dron_metricas"]
]
triggers.each do |nombre_trigger, tabla|
db.execute("DROP TRIGGER IF EXISTS #{nombre_trigger} ON bitacoras.#{tabla}")
db.execute(<<~SQL)
CREATE TRIGGER #{nombre_trigger}
BEFORE UPDATE ON bitacoras.#{tabla}
FOR EACH ROW
EXECUTE FUNCTION bitacoras.update_updated_at_column()
SQL
puts "📈 Trigger creado: #{nombre_trigger} en #{tabla}"
end
# ============================================================================
# Vistas útiles
# ============================================================================
puts "\n📝 Creando vistas útiles..."
# Vista: Drones activos por estado
db.execute(<<~SQL)
CREATE OR REPLACE VIEW bitacoras.vw_dron_estado AS
SELECT
estado,
tipo,
COUNT(*) as cantidad,
MIN(started_at) as mas_antiguo,
MAX(started_at) as mas_reciente
FROM bitacoras.dron_logs
WHERE estado IN ('running', 'pending')
GROUP BY estado, tipo
SQL
puts "✅ Vista creada: vw_dron_estado"
# Vista: Drones zombies
db.execute(<<~SQL)
CREATE OR REPLACE VIEW bitacoras.vw_dron_zombies AS
SELECT
id,
dron_id,
tipo,
cmd,
started_at,
heartbeat,
EXTRACT(EPOCH FROM (NOW() - heartbeat))/60 as minutos_sin_heartbeat
FROM bitacoras.dron_logs
WHERE estado = 'running'
AND heartbeat < NOW() - INTERVAL '5 minutes'
SQL
puts "✅ Vista creada: vw_dron_zombies"
# Vista: Métricas semanales
db.execute(<<~SQL)
CREATE OR REPLACE VIEW bitacoras.vw_dron_metricas_semanal AS
SELECT
fecha,
dron_tipo,
total_ejecuciones,
completados,
fallidos,
zombies,
tiempo_promedio,
reintentos_total,
ROUND(100.0 * completados / NULLIF(total_ejecuciones, 0), 2) as tasa_exito
FROM bitacoras.dron_metricas
WHERE fecha >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY fecha DESC, dron_tipo
SQL
puts "✅ Vista creada: vw_dron_metricas_semanal"
# ============================================================================
# Comentarios de documentación
# ============================================================================
puts "\n📝 Agregando comentarios de documentación..."
db.execute("COMMENT ON TABLE bitacoras.dron_logs IS 'Logs detallados de cada dron en la colonia (NO visible directamente en Bitácora Web)'")
db.execute("COMMENT ON TABLE bitacoras.dron_avances IS 'Avances paso-a-paso de flujos multi-paso (orquestación)'")
db.execute("COMMENT ON TABLE bitacoras.dron_metricas IS 'Métricas agregadas diarias por tipo de dron (para dashboard y estadísticas)'")
puts "✅ Comentarios agregados"
end
puts "\n🎉 Migration 003 completed successfully!"
puts "\nResumen:"
puts " - bitacoras.dron_logs: Logs detallados"
puts " - bitacoras.dron_avances: Avances paso-a-paso"
puts " - bitacoras.dron_metricas: Métricas agregadas"
puts " - Vistas: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal"
puts "\n🏁 Migration finished."
@@ -0,0 +1,177 @@
-- Migración 003: Tablas para sistema de Drones (AtomicDrones)
-- Fecha: 2026-04-07
-- Propósito: Crear tablas internas para registro detallado de drones
-- ============================================================================
-- Tabla 1: dron_logs - Logs detallados de cada dron
-- ============================================================================
CREATE TABLE IF NOT EXISTS bitacoras.dron_logs (
id SERIAL PRIMARY KEY,
dron_id VARCHAR(50) NOT NULL, -- ej: dron_083045_123
flujo_id VARCHAR(50), -- ID de orquestación (si aplica)
tipo VARCHAR(20) NOT NULL, -- ejecutor|vigilante|sanador|planificador|mensajero|orquestador
estado VARCHAR(20) NOT NULL, -- pending|running|completed|failed|zombie
cmd TEXT, -- Comando ejecutado
output TEXT, -- Salida del comando
exit_code INTEGER, -- Código de retorno
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
heartbeat TIMESTAMP DEFAULT NOW(), -- Último heartbeat
reintentos INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}'::jsonb, -- Metadata adicional
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Índices para consultas eficientes
CREATE INDEX IF NOT EXISTS idx_dron_logs_estado ON bitacoras.dron_logs(estado);
CREATE INDEX IF NOT EXISTS idx_dron_logs_heartbeat ON bitacoras.dron_logs(heartbeat);
CREATE INDEX IF NOT EXISTS idx_dron_logs_flujo ON bitacoras.dron_logs(flujo_id);
CREATE INDEX IF NOT EXISTS idx_dron_logs_tipo ON bitacoras.dron_logs(tipo);
CREATE INDEX IF NOT EXISTS idx_dron_logs_started_at ON bitacoras.dron_logs(started_at);
-- ============================================================================
-- Tabla 2: dron_avances - Avances paso-a-paso (para flujos multi-paso)
-- ============================================================================
CREATE TABLE IF NOT EXISTS bitacoras.dron_avances (
id SERIAL PRIMARY KEY,
dron_id VARCHAR(50) NOT NULL,
flujo_id VARCHAR(50), -- ID de orquestación
paso INTEGER NOT NULL, -- Número de paso en flujo
descripcion TEXT NOT NULL, -- Qué se está haciendo
estado VARCHAR(20) NOT NULL, -- pending|running|completed|failed
output TEXT, -- Salida del paso
started_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
-- Índices para consultas eficientes
CREATE INDEX IF NOT EXISTS idx_dron_avances_dron ON bitacoras.dron_avances(dron_id);
CREATE INDEX IF NOT EXISTS idx_dron_avances_flujo ON bitacoras.dron_avances(flujo_id);
CREATE INDEX IF NOT EXISTS idx_dron_avances_paso ON bitacoras.dron_avances(paso);
-- ============================================================================
-- Tabla 3: dron_metricas - Métricas de rendimiento (agregaciones diarias)
-- ============================================================================
CREATE TABLE IF NOT EXISTS bitacoras.dron_metricas (
id SERIAL PRIMARY KEY,
fecha DATE NOT NULL,
dron_tipo VARCHAR(20) NOT NULL,
total_ejecuciones INTEGER DEFAULT 0,
completados INTEGER DEFAULT 0,
fallidos INTEGER DEFAULT 0,
zombies INTEGER DEFAULT 0,
tiempo_promedio INTERVAL DEFAULT '0 seconds',
reintentos_total INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(fecha, dron_tipo)
);
-- Índice para consultas por fecha
CREATE INDEX IF NOT EXISTS idx_dron_metricas_fecha ON bitacoras.dron_metricas(fecha);
-- ============================================================================
-- Trigger: Actualizar updated_at automáticamente
-- ============================================================================
-- Función para actualizar updated_at
CREATE OR REPLACE FUNCTION bitacoras.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger para dron_logs
DROP TRIGGER IF EXISTS update_dron_logs_updated_at ON bitacoras.dron_logs;
CREATE TRIGGER update_dron_logs_updated_at
BEFORE UPDATE ON bitacoras.dron_logs
FOR EACH ROW
EXECUTE FUNCTION bitacoras.update_updated_at_column();
-- Trigger para dron_metricas
DROP TRIGGER IF EXISTS update_dron_metricas_updated_at ON bitacoras.dron_metricas;
CREATE TRIGGER update_dron_metricas_updated_at
BEFORE UPDATE ON bitacoras.dron_metricas
FOR EACH ROW
EXECUTE FUNCTION bitacoras.update_updated_at_column();
-- ============================================================================
-- Comentario de documentación
-- ============================================================================
COMMENT ON TABLE bitacoras.dron_logs IS 'Logs detallados de cada dron en la colonia (NO visible directamente en Bitácora Web)';
COMMENT ON TABLE bitacoras.dron_avances IS 'Avances paso-a-paso de flujos multi-paso (orquestación)';
COMMENT ON TABLE bitacoras.dron_metricas IS 'Métricas agregadas diarias por tipo de dron (para dashboard y estadísticas)';
COMMENT ON COLUMN bitacoras.dron_logs.dron_id IS 'Identificador único del dron (ej: dron_083045_123)';
COMMENT ON COLUMN bitacoras.dron_logs.flujo_id IS 'ID de orquestación para agrupar drones de un mismo flujo';
COMMENT ON COLUMN bitacoras.dron_logs.tipo IS 'Tipo de dron: ejecutor, vigilante, sanador, planificador, mensajero, orquestador';
COMMENT ON COLUMN bitacoras.dron_logs.estado IS 'Estado actual: pending, running, completed, failed, zombie';
COMMENT ON COLUMN bitacoras.dron_logs.heartbeat IS 'Último heartbeat - si > 5min, dron se considera zombie';
-- ============================================================================
-- Vistas útiles para consultas frecuentes
-- ============================================================================
-- Vista: Drones activos por estado
CREATE OR REPLACE VIEW bitacoras.vw_dron_estado AS
SELECT
estado,
tipo,
COUNT(*) as cantidad,
MIN(started_at) as mas_antiguo,
MAX(started_at) as mas_reciente
FROM bitacoras.dron_logs
WHERE estado IN ('running', 'pending')
GROUP BY estado, tipo;
-- Vista: Drones zombies (heartbeat > 5min)
CREATE OR REPLACE VIEW bitacoras.vw_dron_zombies AS
SELECT
id,
dron_id,
tipo,
cmd,
started_at,
heartbeat,
EXTRACT(EPOCH FROM (NOW() - heartbeat))/60 as minutos_sin_heartbeat
FROM bitacoras.dron_logs
WHERE estado = 'running'
AND heartbeat < NOW() - INTERVAL '5 minutes';
-- Vista: Métricas de los últimos 7 días
CREATE OR REPLACE VIEW bitacoras.vw_dron_metricas_semanal AS
SELECT
fecha,
dron_tipo,
total_ejecuciones,
completados,
fallidos,
zombies,
tiempo_promedio,
reintentos_total,
ROUND(100.0 * completados / NULLIF(total_ejecuciones, 0), 2) as tasa_exito
FROM bitacoras.dron_metricas
WHERE fecha >= CURRENT_DATE - INTERVAL '7 days'
ORDER BY fecha DESC, dron_tipo;
-- ============================================================================
-- Mensaje de confirmación
-- ============================================================================
DO $$
BEGIN
RAISE NOTICE '✅ Migración 003 completada: Tablas de Drones creadas exitosamente';
RAISE NOTICE ' - bitacoras.dron_logs: Logs detallados';
RAISE NOTICE ' - bitacoras.dron_avances: Avances paso-a-paso';
RAISE NOTICE ' - bitacoras.dron_metricas: Métricas agregadas';
RAISE NOTICE ' - Vistas: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal';
END $$;