#!/usr/bin/env ruby # frozen_string_literal: true # ========================================================== # adn/tools/cli/copiloto.rb — Vigía de tareas largas # ---------------------------------------------------------- # El "copiloto" se encarga de tareas de bajo nivel: # - Lanzar un comando y vigilar hasta que termine # - Cerrar automáticamente el evento de bitácora al finalizar # - Registrar resultado (éxito/fallo) en la descripción # # Principio: Menos es Más — una tool reutilizable para # cualquier ámbito que necesite delegar tareas largas. # # Uso: # ./adn/tools/run copiloto lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch # ./adn/tools/run copiloto vigilar --evento 1379 --pid 12345 # ./adn/tools/run copiloto estado # ========================================================== require 'optparse' require 'fileutils' require 'time' require 'json' module ADN class SubcomandoCopiloto COPILOTO_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'copiloto') ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run') def initialize(args, logger = nil) @args = args @logger = logger FileUtils.mkdir_p(COPILOTO_DIR) 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 '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 copiloto 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 copiloto lanzar --evento 1379 -- " return end copiloto_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!(copiloto_args) # AUTO: crear evento de bitácora automáticamente if evento_id == 'AUTO' cmd_crear = "#{ADN_RUN} db evento:crear --ambito dtic-BKPs --nodo srv-ns8" cmd_crear += " --descripcion \"#{(nota || comando_args.join(' ')).gsub('"', '\\"')}\"" cmd_crear += " --inicio #{Time.now.strftime('%H:%M')} --modo R --ia" output = `#{cmd_crear} 2>&1` # Extraer ID del evento creado if output.match(/ID\s+(\d+)/) evento_id = output.match(/ID\s+(\d+)/)[1].to_i @logger&.info("Copiloto: Evento AUTO ##{evento_id} creado") else @logger&.info("Copiloto: No se pudo crear evento AUTO, continuando sin evento") 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 = "copiloto_#{Time.now.strftime('%H%M%S')}_#{$$}" log_file = File.join(COPILOTO_DIR, "#{tarea_id}.log") meta_file = File.join(COPILOTO_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 vigia_script = <<~BASH #!/bin/bash # Copiloto vigía — auto-generado LOGFILE="#{log_file}" METAFILE="#{meta_file}" EVENTO_ID="#{evento_id}" ADN_RUN="#{ADN_RUN}" echo "[$(date)] Copiloto: Iniciando comando..." >> "$LOGFILE" #{comando_str} >> "$LOGFILE" 2>&1 EXIT_CODE=$? echo "[$(date)] Copiloto: Comando terminó con código $EXIT_CODE" >> "$LOGFILE" # Actualizar metadata ruby -rjson -e ' f = "'"$METAFILE"'" m = JSON.parse(File.read(f)) m["fin"] = Time.now.iso8601 m["exit_code"] = #{'"$EXIT_CODE".to_i'} m["estado"] = #{'"$EXIT_CODE".to_i'} == 0 ? "completado" : "fallido" File.write(f, JSON.pretty_generate(m)) ' # Cerrar evento en bitácora si hay evento_id if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then HORA_FIN=$(date +%H:%M) DURACION=$(( ($(date +%s) - #{Time.now.to_i}) / 60 )) if [ "$EXIT_CODE" -eq 0 ]; then RESULTADO="- 🤖 Copiloto: ✔ Completado exitosamente (${DURACION}min)" else RESULTADO="- 🤖 Copiloto: ✖ Falló con código $EXIT_CODE (${DURACION}min)" fi NOTA_BASE="#{(nota || '').gsub('"', '\\"')}" if [ -n "$NOTA_BASE" ]; then DESC_FINAL="${NOTA_BASE} ${RESULTADO}" else DESC_FINAL="#{comando_str} ${RESULTADO}" fi "$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN" --descripcion "$DESC_FINAL" >> "$LOGFILE" 2>&1 echo "[$(date)] Copiloto: Evento $EVENTO_ID cerrado a las $HORA_FIN ($RESULTADO)" >> "$LOGFILE" fi BASH vigia_path = File.join(COPILOTO_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}✔ Copiloto 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 copiloto cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id puts "#{Color::DIM}Consultar: ./adn/tools/run copiloto 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(COPILOTO_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)] Copiloto: PID #{pid} terminó" >> #{log_file} #{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""} echo "[$(date)] Copiloto: 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(COPILOTO_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta)) puts "#{Color::GREEN}✔ Copiloto vigilando PID #{pid}#{Color::RESET}" puts " Evento: #{evento_id || '(sin evento)'}" puts " Log: #{log_file}" end # ─── estado: mostrar tareas del copiloto ─────────────────────── def cmd_estado metas = Dir.glob(File.join(COPILOTO_DIR, '*.json')).sort if metas.empty? puts "#{Color::DIM}Sin tareas de copiloto activas.#{Color::RESET}" return end puts "#{Color::BOLD}📋 Tareas del Copiloto#{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 # ─── limpiar: borrar tareas completadas ────────────────────── def cmd_limpiar Dir.glob(File.join(COPILOTO_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(COPILOTO_DIR, "#{base}#{ext}")) end end end puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}" end def mostrar_ayuda puts <<~HELP #{Color::BOLD}#{Color::CYAN}Copiloto — Vigía de Tareas Largas#{Color::RESET} El copiloto se encarga de las tareas de bajo nivel: ejecutar comandos largos en background y cerrar automáticamente eventos de bitácora cuando terminan. Delegar sin desperdiciar recursos. #{Color::YELLOW}Uso:#{Color::RESET} copiloto lanzar --evento ID -- Lanzar y vigilar copiloto vigilar --evento ID --pid PID Vigilar PID existente copiloto estado Ver tareas activas copiloto limpiar Limpiar completadas #{Color::YELLOW}Ejemplos:#{Color::RESET} ./adn/tools/run copiloto lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch ./adn/tools/run copiloto vigilar --evento 1379 --pid 12345 ./adn/tools/run copiloto estado HELP end end end