🛸 Fase 1.5 completa: AtomicDrones con visibilidad en Bitácora Web

Bugs corregidos (12): B1-B12 — crashes, tipos, métodos, logger
Ejecutor reescrito: evento web → heartbeat 30s → cierre automático
copiloto.rb y novato.rb deprecados → redirigen a dron lanzar
Plan P009 v3.2 — Fase 1.5 100%% completa
This commit is contained in:
Ricardo Monla
2026-04-09 10:12:04 -03:00
parent fb8c8eefe5
commit 6d7394b22e
9 changed files with 415 additions and 147 deletions
+24 -18
View File
@@ -35,25 +35,31 @@ module ADN
end
def ejecutar
if @args.empty? || @args.first == 'help' || @args.first == '--help'
mostrar_ayuda
return
end
puts "\n#{Color::YELLOW}⚠️ DEPRECADO: 'copiloto' ha sido reemplazado por 'dron lanzar'#{Color::RESET}"
puts ""
puts " El sistema AtomicDrones (Fase 1.5) reemplaza al copiloto con:"
puts " - Heartbeat visible en Bitácora Web cada 30s"
puts " - Timeout real implementado"
puts " - Registro automático en tablas internas + events"
puts ""
puts " #{Color::CYAN}Equivalencias:#{Color::RESET}"
puts " #{Color::DIM}copiloto lanzar --evento ID -- <cmd>#{Color::RESET}"
puts " #{Color::GREEN}→ dron lanzar --evento ID --nota \"...\" -- <cmd>#{Color::RESET}"
puts ""
puts " #{Color::DIM}copiloto vigilar --evento ID --pid PID#{Color::RESET}"
puts " #{Color::GREEN}→ dron flota (para ver drones activos)#{Color::RESET}"
puts ""
puts " #{Color::DIM}copiloto estado#{Color::RESET}"
puts " #{Color::GREEN}→ dron flota#{Color::RESET}"
puts ""
accion = @args.shift
case accion
when 'lanzar'
cmd_lanzar(@args)
when 'vigilar'
cmd_vigilar(@args)
when 'estado'
cmd_estado
when 'limpiar'
cmd_limpiar
else
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
mostrar_ayuda
exit 1
# Si hay argumentos, intentar redirigir automáticamente
if @args.first == 'lanzar'
@args.shift
puts "#{Color::YELLOW}Redirigiendo a 'dron lanzar'...#{Color::RESET}\n\n"
require_relative 'dron'
dron = ADN::SubcomandoDron.new(['lanzar'] + @args)
dron.ejecutar
end
end
+1 -1
View File
@@ -27,7 +27,7 @@ module ADN
class SubcomandoDron
def initialize(args, logger = nil)
@args = args
@logger = logger || ADN::Logger.instance
@logger = logger || ADN::Logger.new
end
def ejecutar
+2 -3
View File
@@ -209,9 +209,8 @@ module Dron
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])
require_relative '../../db/core/dron_db'
avances = ADN::DB::DronDB.obtener_avances(dron_id)
unless avances.empty?
puts "\n📝 Avances:"
+182 -48
View File
@@ -3,9 +3,19 @@
# Dron::Ejecutor — Módulo atómico para ejecución de comandos
#
# Responsabilidad única: Ejecutar un comando y reportar resultado
#
# Fase 1.5: Reescrito para visibilidad en Bitácora Web
# - Heartbeat actualiza events.descripcion (visible en web)
# - Flujo simplificado: crear evento → heartbeat → cierre limpio
# - Timeout implementado con Timeout.timeout()
# - Errores de bitácora logeados explícitamente (sin rescue silencioso)
require_relative 'base'
require_relative '../../db/core/dron_db'
require 'open3'
require 'timeout'
require 'time'
require 'json'
module Dron
class Ejecutor
@@ -15,44 +25,86 @@ module Dron
class << self
def lanzar(cmd:, nota: nil, evento_id: nil, timeout: DEFAULT_TIMEOUT, flujo_id: nil, nodo: nil)
Base.logger.info("🛠️ Dron Ejecutor iniciado: #{Process.pid}")
dron_id = ADN::DB::DronDB.generar_dron_id
inicio = Time.now
ADN::DB::DronDB.registrar_inicio(tipo: 'ejecutor', cmd: cmd, flujo_id: flujo_id, metadata: { nota: nota, evento_id: evento_id, timeout: timeout, nodo: nodo })
evento_id = registrar_bitacora(nota, evento_id, 'inicio', nodo) if nota
resultado = ejecutar_comando(cmd, dron_id, timeout)
# 1. Registrar en tablas internas (dron_logs)
resultado_inicio = ADN::DB::DronDB.registrar_inicio(
tipo: 'ejecutor', cmd: cmd, flujo_id: flujo_id,
metadata: { nota: nota, evento_id: evento_id, timeout: timeout, nodo: nodo }
)
dron_id = resultado_inicio[:dron_id]
ADN::DB::DronDB.registrar_avance(
dron_id, paso: 1,
descripcion: "Despegue inicial. Ejecutando comando...",
estado: 'running', flujo_id: flujo_id
)
# 2. Crear/vincular evento en Bitácora Web (visible)
evento_id = crear_o_vincular_evento_web(dron_id, nota, evento_id, cmd, nodo)
# 3. Ejecutar comando con heartbeat que actualiza AMBAS capas
resultado = ejecutar_comando(cmd, dron_id, timeout, evento_id, nota, inicio)
# 4. Registrar fin en tablas internas
ADN::DB::DronDB.registrar_fin(dron_id, resultado[:exit_code], resultado[:output])
registrar_bitacora(nota, evento_id, 'fin', resultado, nodo) if evento_id
estado_fin = resultado[:exit_code] == 0 ? 'completed' : 'failed'
desc_fin = resultado[:exit_code] == 0 ?
"Aterrizaje exitoso. Comando finalizado." :
"Aterrizaje con errores (Code #{resultado[:exit_code]})."
ADN::DB::DronDB.registrar_avance(
dron_id, paso: 2,
descripcion: desc_fin,
estado: estado_fin, flujo_id: flujo_id
)
# 5. Cerrar evento en Bitácora Web (visible)
cerrar_evento_web(evento_id, dron_id, nota, cmd, resultado, inicio)
Base.logger.info("🛠️ Dron Ejecutor completado: #{dron_id} - #{resultado[:exit_code] == 0 ? '✅' : '❌'}")
{ dron_id: dron_id, exit_code: resultado[:exit_code], duration: resultado[:duration], evento_id: evento_id }
rescue StandardError => e
Base.logger.error("🛠️ Dron Ejecutor falló: #{e.message}")
Base.logger.error(" #{e.backtrace&.first(3)&.join("\n ")}")
{ dron_id: nil, exit_code: 1, output: e.message, duration: 0, error: e.message }
end
private
def ejecutar_comando(cmd, dron_id, timeout)
# ─── Ejecución con heartbeat dual ─────────────────────────────
def ejecutar_comando(cmd, dron_id, timeout, evento_id, nota, inicio)
start_time = Time.now
output = []
exit_code = nil
heartbeat_thread = Thread.new do
tick = 0
loop do
sleep(HEARTBEAT_INTERVAL)
tick += 1
# Actualizar tabla interna (dron_logs.heartbeat)
ADN::DB::DronDB.actualizar_heartbeat(dron_id)
# Actualizar Bitácora Web (events.descripcion) — VISIBLE para el usuario
actualizar_heartbeat_web(evento_id, dron_id, nota, cmd, inicio, tick) if evento_id
rescue StandardError => e
Base.logger.warn("⚠️ Error en heartbeat ##{tick}: #{e.message}")
end
end
begin
require 'open3'
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
# Leer stdout y stderr en threads separados
out_thread = Thread.new { output << stdout.read }
err_thread = Thread.new { output << stderr.read }
out_thread.join
err_thread.join
exit_code = wait_thr.value.exitstatus
Timeout.timeout(timeout) do
Open3.popen3(cmd) do |_stdin, stdout, stderr, wait_thr|
out_thread = Thread.new { output << stdout.read }
err_thread = Thread.new { output << stderr.read }
out_thread.join
err_thread.join
exit_code = wait_thr.value.exitstatus
end
end
rescue Timeout::Error
exit_code = 124
@@ -67,59 +119,141 @@ module Dron
{ exit_code: exit_code || 1, output: output.join("\n").strip, duration: (Time.now - start_time).round(2) }
end
def registrar_bitacora(nota, evento_id, tipo, resultado = nil, nodo = nil)
return nil unless nota
require_relative '../../db/core/bitacora_db'
# ─── Bitácora Web: Crear/Vincular evento ─────────────────────
def crear_o_vincular_evento_web(dron_id, nota, evento_id, cmd, nodo)
return nil unless nota # Sin nota = sin registro en web
# Detectar nodo automáticamente si no se especifica
nodo_id = detectar_nodo_id(nodo)
if tipo == 'inicio'
descripcion = "🛸 dron: #{nota}"
BitacorasDB::BitacoraDB.with_connection do |db|
evento = db.crear_evento(nodo_id: nodo_id, descripcion: descripcion, inicio: Time.now.strftime('%H:%M'), estado: '⏳')
evento['id'] # Retornar ID para actualizar después
BitacorasDB::BitacoraDB.with_connection do |db|
if evento_id.to_i > 0
# Vincular a evento existente: agregar nota de despegue
entrada = db.execute("SELECT descripcion FROM bitacoras.entradas WHERE id = $1", [evento_id]).first
target_table = entrada ? 'entradas' : 'events'
target = entrada || db.execute("SELECT descripcion FROM bitacoras.events WHERE id = $1", [evento_id]).first
if target
nueva_desc = target['descripcion'].to_s +
"\n\n🛸 **[#{Time.now.strftime('%H:%M')}] Vuelo Iniciado** (`#{dron_id}`): #{nota}" +
"\n├─ Comando: `#{cmd}`"
if target_table == 'entradas'
db.update_entrada(evento_id, descripcion: nueva_desc)
else
db.actualizar_evento(evento_id, descripcion: nueva_desc)
end
end
evento_id
else
# Crear evento nuevo en bitácora web
descripcion = "🛸 #{nota} (`#{dron_id}`)\n├─ Comando: `#{cmd}`\n└─ ⏳ Iniciando..."
bitacora_hoy = db.execute("SELECT id FROM bitacoras.bitacoras WHERE fecha::date = CURRENT_DATE LIMIT 1").first
if bitacora_hoy
entrada = db.create_entrada(
inicio: Time.now.strftime('%H:%M'),
descripcion: descripcion,
estado: '⏳',
modo: 'R',
bitacora_id: bitacora_hoy['id'],
nodo_id: nodo_id
)
entrada['id']
else
evento = db.crear_evento(
nodo_id: nodo_id,
descripcion: descripcion,
inicio: Time.now.strftime('%H:%M'),
estado: '⏳',
modo: 'R',
metadata: { dron_id: dron_id }
)
evento['id']
end
end
else
estado = resultado[:exit_code] == 0 ? '✅' : '❌'
descripcion = "🛸 dron: #{nota}#{estado} (#{Base.formato_duracion(resultado[:duration])})"
BitacorasDB::BitacoraDB.with_connection do |db|
db.actualizar_evento(evento_id, descripcion: descripcion, fin: Time.now.strftime('%H:%M'))
end
evento_id # Retornar mismo ID
end
rescue StandardError => e
Base.logger.warn("⚠️ Error en bitácora: #{e.message}")
Base.logger.error("🛸 Error creando evento web: #{e.message}")
Base.logger.error(" #{e.backtrace&.first(3)&.join("\n ")}")
nil
end
# ─── Bitácora Web: Heartbeat visible ─────────────────────────
def actualizar_heartbeat_web(evento_id, dron_id, nota, cmd, inicio, tick)
duracion = Base.formato_duracion(Time.now - inicio)
descripcion = "🛸 #{nota} (`#{dron_id}`)\n" \
"├─ Comando: `#{cmd}`\n" \
"└─ ❤️ #{duracion} — heartbeat ##{tick}"
BitacorasDB::BitacoraDB.with_connection do |db|
# Intentar actualizar como entrada primero, luego como evento
entrada = db.execute("SELECT id FROM bitacoras.entradas WHERE id = $1", [evento_id]).first
if entrada
db.update_entrada(evento_id, descripcion: descripcion)
else
db.actualizar_evento(evento_id, descripcion: descripcion)
end
end
rescue StandardError => e
Base.logger.warn("⚠️ Heartbeat web ##{tick} falló: #{e.message}")
end
# ─── Bitácora Web: Cierre de evento ──────────────────────────
def cerrar_evento_web(evento_id, dron_id, nota, cmd, resultado, inicio)
return unless evento_id.to_i > 0
duracion = Base.formato_duracion(resultado[:duration])
estado = resultado[:exit_code] == 0 ? '✅' : '❌'
aterrizaje = resultado[:exit_code] == 0 ? 'Aterrizaje limpio' : "Fallo (code #{resultado[:exit_code]})"
# Construir descripción final con output resumido
output_resumen = Base.slice_safe(resultado[:output], 500)
descripcion = "🛸 #{nota} (`#{dron_id}`) — #{estado} #{duracion}\n" \
"├─ Comando: `#{cmd}`\n" \
"└─ #{aterrizaje}"
descripcion += "\n\n```text\n#{output_resumen}\n```" unless output_resumen.empty?
BitacorasDB::BitacoraDB.with_connection do |db|
entrada = db.execute("SELECT id FROM bitacoras.entradas WHERE id = $1", [evento_id]).first
if entrada
db.update_entrada(evento_id,
descripcion: descripcion,
estado: estado,
fin: Time.now.strftime('%H:%M')
)
else
db.actualizar_evento(evento_id,
descripcion: descripcion,
estado: estado,
fin: Time.now.strftime('%H:%M')
)
end
end
Base.logger.info("🛸 Evento ##{evento_id} cerrado (#{estado})")
rescue StandardError => e
Base.logger.error("🛸 Error cerrando evento web ##{evento_id}: #{e.message}")
Base.logger.error(" #{e.backtrace&.first(3)&.join("\n ")}")
end
# ─── Helpers ─────────────────────────────────────────────────
def detectar_nodo_id(nodo)
return nodo if nodo.is_a?(Integer)
# Si no se especifica nodo, usar el hostname para detectar
require 'socket'
hostname = Socket.gethostname.downcase
# Buscar nodo por nombre en la tabla de nodos
require_relative '../../db/core/bitacora_db'
sql = 'SELECT id FROM nodos WHERE LOWER(nombre) = $1 LIMIT 1'
resultado = nil
BitacorasDB::BitacoraDB.with_connection do |db|
resultado = db.execute(sql, [hostname])
resultado = db.execute('SELECT id FROM nodos WHERE LOWER(nombre) = $1 LIMIT 1', [hostname])
if resultado.nil? || resultado.ntuples == 0
resultado = db.execute('SELECT id FROM nodos WHERE LOWER(nombre) LIKE $1 LIMIT 1', ["#{hostname}%"])
end
end
if resultado&.any?
resultado.first['id']
else
# Fallback: buscar por prefijo (ej: srv-ns8 coincide con srv-ns8.frlr.utn.edu.ar)
sql = 'SELECT id FROM nodos WHERE LOWER(nombre) LIKE $1 LIMIT 1'
resultado = nil
BitacorasDB::BitacoraDB.with_connection do |db|
resultado = db.execute(sql, ["#{hostname}%"])
end
resultado&.any? ? resultado.first['id'] : 1
end
(resultado&.any?) ? resultado.first['id'] : 1
rescue StandardError => e
Base.logger.warn("⚠️ Error detectando nodo: #{e.message}, usando default (1)")
1
+7 -2
View File
@@ -6,6 +6,9 @@
require_relative 'base'
require_relative '../../db/core/dron_db'
require_relative 'ejecutor'
require 'json'
require 'time'
module Dron
class Sanador
@@ -24,7 +27,8 @@ module Dron
fallidos.each do |dron|
espera = BACKOFF_BASE * (2 ** dron['reintentos'].to_i)
tiempo_desde_fallo = Time.now - dron['completed_at']
completed_at = dron['completed_at'].is_a?(String) ? Time.parse(dron['completed_at']) : dron['completed_at']
tiempo_desde_fallo = Time.now - completed_at
if tiempo_desde_fallo < espera
Base.logger.debug("#{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)")
@@ -38,7 +42,8 @@ module Dron
else
Base.logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{dron['reintentos'].to_i + 1}/#{MAX_REINTENTOS})")
ADN::DB::DronDB.incrementar_reintentos(dron['dron_id'])
resultado = Dron::Ejecutor.lanzar(cmd: dron['cmd'], nota: "Reintento: #{dron.dig('metadata', 'nota')}", flujo_id: dron['flujo_id'])
meta = dron['metadata'].is_a?(String) ? (JSON.parse(dron['metadata']) rescue {}) : (dron['metadata'] || {})
resultado = Dron::Ejecutor.lanzar(cmd: dron['cmd'], nota: "Reintento: #{meta['nota']}", flujo_id: dron['flujo_id'])
reintentados += 1 if resultado[:exit_code] == 0
notificar_fallo_persistente(dron) if dron['reintentos'].to_i >= MAX_REINTENTOS && resultado[:exit_code] != 0
end
+6 -5
View File
@@ -136,11 +136,12 @@ module Dron
resultado = db_query("SELECT id FROM bitacoras.events WHERE metadata->>'flujo_id' = $1", [flujo_id])
if resultado.empty?
require_relative '../../db/core/bitacora_db'
BitacorasDB::BitacoraDB.crear_evento(nodo_id: 1, descripcion: descripcion, estado: estado_icono, metadata: { flujo_id: flujo_id, drones_count: count }.to_json)
else
BitacorasDB::BitacoraDB.actualizar_evento(resultado.first['id'], descripcion: descripcion, estado: estado_icono)
BitacorasDB::BitacoraDB.with_connection do |db|
if resultado.empty?
db.crear_evento(nodo_id: 1, descripcion: descripcion, estado: estado_icono, metadata: { flujo_id: flujo_id, drones_count: count })
else
db.actualizar_evento(resultado.first['id'], descripcion: descripcion, estado: estado_icono)
end
end
rescue StandardError => e
logger.warn("⚠️ No se pudo actualizar evento resumen: #{e.message}")
+18 -18
View File
@@ -35,25 +35,25 @@ module ADN
end
def ejecutar
if @args.empty? || @args.first == 'help' || @args.first == '--help'
mostrar_ayuda
return
end
puts "\n#{Color::YELLOW}⚠️ DEPRECADO: 'novato' ha sido reemplazado por 'dron lanzar'#{Color::RESET}"
puts ""
puts " El sistema AtomicDrones (Fase 1.5) reemplaza al novato con:"
puts " - Heartbeat visible en Bitácora Web cada 30s"
puts " - Timeout real implementado"
puts " - Registro automático en tablas internas + events"
puts ""
puts " #{Color::CYAN}Equivalencias:#{Color::RESET}"
puts " #{Color::DIM}novato lanzar --evento ID -- <cmd>#{Color::RESET}"
puts " #{Color::GREEN}→ dron lanzar --evento ID --nota \"...\" -- <cmd>#{Color::RESET}"
puts ""
accion = @args.shift
case accion
when 'lanzar'
cmd_lanzar(@args)
when 'vigilar'
cmd_vigilar(@args)
when 'estado'
cmd_estado
when 'limpiar'
cmd_limpiar
else
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
mostrar_ayuda
exit 1
# Redirigir automáticamente si es 'lanzar'
if @args.first == 'lanzar'
@args.shift
puts "#{Color::YELLOW}Redirigiendo a 'dron lanzar'...#{Color::RESET}\n\n"
require_relative 'dron'
dron = ADN::SubcomandoDron.new(['lanzar'] + @args)
dron.ejecutar
end
end