From 1af0457c1fd647133a98586d15c57c5f32ac5192 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Fri, 6 Mar 2026 09:05:57 +0000 Subject: [PATCH] feat: update dashboard and process manager for unified launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actualiza el dashboard TUI y el process manager para el modelo de launcher unificado donde todos los agentes corren en un solo proceso. Dashboard (pkg/tui): - model.go: campos de estado del launcher (PID, uptime, memory, CPU, log size) - model.go: ServerMenuOptions(running) contextual, AgentActionOptions(enabled) - messages.go: MsgAgentsLoaded incluye estado del launcher, MsgServerActionDone/MsgRebuildDone simplificados - update.go: intents nuevos (Enable/Disable agent, Start/Stop/Restart/Kill launcher) - view.go: vista de servidor muestra stats del launcher, agentes muestran enabled/disabled Shell adapter (shell/tui): - adapter.go: reescrito para usar métodos unificados (StartUnified, StopUnified, ToggleEnabled, StatusAllUnified, UnifiedStats, UnifiedLogTail) Process manager (shell/process): - manager.go: métodos StartUnified, StopUnified, KillUnified, IsUnifiedRunning, UnifiedPID, UnifiedStats, UnifiedLogTail, StatusAllUnified, ToggleEnabled Los agentes ya no se inician/detienen individualmente desde el dashboard. Se habilitan/deshabilitan en config y se reinicia el launcher para aplicar. Co-Authored-By: Claude Opus 4.6 --- pkg/tui/messages.go | 33 ++-- pkg/tui/model.go | 57 ++++--- pkg/tui/update.go | 127 ++++++++------- pkg/tui/view.go | 83 +++++----- shell/process/manager.go | 180 ++++++++++++++++++++++ shell/tui/adapter.go | 322 +++++++++++---------------------------- 6 files changed, 432 insertions(+), 370 deletions(-) diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index 8b94bfa..e473b9a 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -3,34 +3,39 @@ package tui // Messages are pure data returned by the shell adapter. // They carry the result of an I/O operation back into the pure Update. -// MsgAgentsLoaded carries refreshed agent data. -type MsgAgentsLoaded struct{ Agents []AgentView } +// MsgAgentsLoaded carries refreshed agent data + launcher status. +type MsgAgentsLoaded struct { + Agents []AgentView + LauncherRunning bool + LauncherPID int + LauncherUptime string + LauncherMemory string + LauncherCPU string + LauncherLogSize string +} -// MsgActionDone reports the result of an agent action (start/stop/kill/restart). +// MsgActionDone reports the result of an action (start/stop/enable/disable). type MsgActionDone struct { AgentID string Action string Err error } -// MsgLogsLoaded carries log lines for the selected agent. +// MsgLogsLoaded carries log lines for display. type MsgLogsLoaded struct{ Lines []string } -// MsgServerActionDone reports the result of a server-wide bulk action. +// MsgServerActionDone reports the result of a launcher action. type MsgServerActionDone struct { - Action string - Total int - Failed int - Errors []string + Action string + Err error } // MsgRebuildDone reports the result of a rebuild & restart cycle. type MsgRebuildDone struct { - BuildOK bool - BuildLog string // last lines of build output - Restarted int // agents restarted after build - Failed int - Errors []string + BuildOK bool + BuildLog string // last lines of build output + Started bool // launcher started after build + Err error } // MsgTick triggers a periodic refresh. diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 3023e1b..3c1d338 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -24,17 +24,25 @@ type Model struct { StatusMsg string // flash message ("Started OK", "Error: ...") WindowWidth int WindowHeight int + + // Unified launcher state + LauncherRunning bool + LauncherPID int + LauncherUptime string + LauncherMemory string + LauncherCPU string + LauncherLogSize string } // AgentView is a pre-formatted projection of an agent for display. type AgentView struct { - ID string - Name string - Version string - Desc string - Enabled bool - Running bool - PID int + ID string + Name string + Version string + Desc string + Enabled bool + Running bool + PID int Instances int // number of running instances (>1 means duplicates) Uptime string // formatted: "2h 15m" Memory string // formatted: "42 MB" @@ -52,36 +60,39 @@ type MenuOption struct { func MainMenuOptions() []MenuOption { return []MenuOption{ {Label: "Agents", Desc: "Gestionar agentes"}, - {Label: "Server", Desc: "Gestionar servidor"}, + {Label: "Server", Desc: "Gestionar launcher unificado"}, {Label: "Quit", Desc: "Salir"}, } } // ServerMenuOptions returns the available server-wide actions. -func ServerMenuOptions() []MenuOption { +func ServerMenuOptions(running bool) []MenuOption { + if running { + return []MenuOption{ + {Label: "Stop", Desc: "Detener el launcher"}, + {Label: "Restart", Desc: "Reiniciar el launcher"}, + {Label: "Kill", Desc: "SIGKILL forzado"}, + {Label: "Rebuild & Restart", Desc: "Build + reiniciar"}, + {Label: "Logs", Desc: "Ver log del launcher"}, + } + } return []MenuOption{ - {Label: "Start All", Desc: "Iniciar todos los agentes habilitados"}, - {Label: "Stop All", Desc: "Detener todos los agentes"}, - {Label: "Restart All", Desc: "Reiniciar todos los agentes"}, - {Label: "Kill All", Desc: "SIGKILL forzado a todos"}, - {Label: "Rebuild & Restart", Desc: "Build + reiniciar activos"}, + {Label: "Start", Desc: "Iniciar el launcher unificado"}, + {Label: "Rebuild & Restart", Desc: "Build + iniciar"}, } } // AgentActionOptions returns the available actions based on agent state. -func AgentActionOptions(running bool) []MenuOption { - if running { +func AgentActionOptions(enabled bool) []MenuOption { + if enabled { return []MenuOption{ - {Label: "Start", Desc: "Iniciar otra instancia"}, - {Label: "Stop", Desc: "Detener todas las instancias"}, - {Label: "Restart", Desc: "Reiniciar (stop all + start)"}, - {Label: "Kill", Desc: "SIGKILL forzado a todas"}, - {Label: "Logs", Desc: "Ver log del agente"}, + {Label: "Disable", Desc: "Desactivar agente (requiere restart)"}, + {Label: "Logs", Desc: "Ver log del launcher"}, } } return []MenuOption{ - {Label: "Start", Desc: "Iniciar el agente"}, - {Label: "Logs", Desc: "Ver log del agente"}, + {Label: "Enable", Desc: "Activar agente (requiere restart)"}, + {Label: "Logs", Desc: "Ver log del launcher"}, } } diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 7a52bd2..6b07418 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -6,20 +6,20 @@ import "fmt" type IntentKind string const ( - IntentLoadAgents IntentKind = "load_agents" - IntentStartAgent IntentKind = "start_agent" - IntentStopAgent IntentKind = "stop_agent" - IntentKillAgent IntentKind = "kill_agent" - IntentRestartAgent IntentKind = "restart_agent" - IntentLoadLogs IntentKind = "load_logs" - IntentTick IntentKind = "tick" - IntentQuit IntentKind = "quit" + IntentLoadAgents IntentKind = "load_agents" + IntentLoadLogs IntentKind = "load_logs" + IntentTick IntentKind = "tick" + IntentQuit IntentKind = "quit" - // Server-wide bulk operations - IntentStartAll IntentKind = "start_all" - IntentStopAll IntentKind = "stop_all" - IntentRestartAll IntentKind = "restart_all" - IntentKillAll IntentKind = "kill_all" + // Agent-level + IntentEnableAgent IntentKind = "enable_agent" + IntentDisableAgent IntentKind = "disable_agent" + + // Unified launcher operations + IntentStartLauncher IntentKind = "start_launcher" + IntentStopLauncher IntentKind = "stop_launcher" + IntentRestartLauncher IntentKind = "restart_launcher" + IntentKillLauncher IntentKind = "kill_launcher" IntentRebuildRestart IntentKind = "rebuild_restart" ) @@ -32,7 +32,7 @@ type Intent struct { // KeyMsg is the pure representation of a key press. // The bridge layer converts tea.KeyMsg into this. type KeyMsg struct { - Str string // "up", "down", "enter", "0", "q", "r", etc. + Str string // "up", "down", "enter", "0", "q", "r", etc. } // WindowSizeMsg carries terminal dimensions. @@ -52,7 +52,12 @@ func Update(model Model, msg interface{}) (Model, []Intent) { case MsgAgentsLoaded: model.Agents = m.Agents - // Clamp cursor only on screens that use the agent list + model.LauncherRunning = m.LauncherRunning + model.LauncherPID = m.LauncherPID + model.LauncherUptime = m.LauncherUptime + model.LauncherMemory = m.LauncherMemory + model.LauncherCPU = m.LauncherCPU + model.LauncherLogSize = m.LauncherLogSize if model.Screen == ScreenAgentList { if model.Cursor >= len(model.Agents) && len(model.Agents) > 0 { model.Cursor = len(model.Agents) - 1 @@ -64,25 +69,27 @@ func Update(model Model, msg interface{}) (Model, []Intent) { if m.Err != nil { model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err) } else { - model.StatusMsg = fmt.Sprintf("%s %s OK", m.Action, m.AgentID) + model.StatusMsg = fmt.Sprintf("%s %s OK — restart launcher to apply", m.Action, m.AgentID) + } + return model, []Intent{{Kind: IntentLoadAgents}} + + case MsgServerActionDone: + if m.Err != nil { + model.StatusMsg = fmt.Sprintf("Error: %s: %v", m.Action, m.Err) + } else { + model.StatusMsg = fmt.Sprintf("%s OK", m.Action) } return model, []Intent{{Kind: IntentLoadAgents}} case MsgRebuildDone: if !m.BuildOK { model.StatusMsg = fmt.Sprintf("Build failed: %s", m.BuildLog) - } else if m.Failed > 0 { - model.StatusMsg = fmt.Sprintf("Built OK, %d/%d agents failed to restart", m.Failed, m.Restarted+m.Failed) + } else if m.Err != nil { + model.StatusMsg = fmt.Sprintf("Built OK, start failed: %v", m.Err) + } else if m.Started { + model.StatusMsg = "Built OK, launcher started" } else { - model.StatusMsg = fmt.Sprintf("Built OK, %d agents restarted", m.Restarted) - } - return model, []Intent{{Kind: IntentLoadAgents}} - - case MsgServerActionDone: - if m.Failed == 0 { - model.StatusMsg = fmt.Sprintf("%s: %d agents OK", m.Action, m.Total) - } else { - model.StatusMsg = fmt.Sprintf("%s: %d/%d failed", m.Action, m.Failed, m.Total) + model.StatusMsg = "Built OK" } return model, []Intent{{Kind: IntentLoadAgents}} @@ -102,7 +109,6 @@ func Update(model Model, msg interface{}) (Model, []Intent) { } func updateKey(model Model, key KeyMsg) (Model, []Intent) { - // Global quit if key.Str == "q" && model.Screen == ScreenMain { return model, []Intent{{Kind: IntentQuit}} } @@ -178,7 +184,7 @@ func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) { return model, nil } - opts := AgentActionOptions(model.Selected.Running) + opts := AgentActionOptions(model.Selected.Enabled) switch key.Str { case "0": @@ -202,18 +208,12 @@ func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) { func executeAction(model Model, action string) (Model, []Intent) { id := model.Selected.ID switch action { - case "Start": - model.StatusMsg = "Starting " + id + "..." - return model, []Intent{{Kind: IntentStartAgent, AgentID: id}} - case "Stop": - model.StatusMsg = "Stopping " + id + "..." - return model, []Intent{{Kind: IntentStopAgent, AgentID: id}} - case "Restart": - model.StatusMsg = "Restarting " + id + "..." - return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}} - case "Kill": - model.StatusMsg = "Killing " + id + "..." - return model, []Intent{{Kind: IntentKillAgent, AgentID: id}} + case "Enable": + model.StatusMsg = "Enabling " + id + "..." + return model, []Intent{{Kind: IntentEnableAgent, AgentID: id}} + case "Disable": + model.StatusMsg = "Disabling " + id + "..." + return model, []Intent{{Kind: IntentDisableAgent, AgentID: id}} case "Logs": model.Screen = ScreenLogs model.LogLines = nil @@ -227,7 +227,11 @@ func executeAction(model Model, action string) (Model, []Intent) { func updateLogs(model Model, key KeyMsg) (Model, []Intent) { switch key.Str { case "0": - model.Screen = ScreenAgentActions + if model.Selected != nil { + model.Screen = ScreenAgentActions + } else { + model.Screen = ScreenServer + } model.Cursor = 0 model.LogLines = nil model.LogScroll = 0 @@ -237,15 +241,13 @@ func updateLogs(model Model, key KeyMsg) (Model, []Intent) { maxScroll := max(0, len(model.LogLines)-visibleLogLines(model)) model.LogScroll = min(model.LogScroll+1, maxScroll) case "r": - if model.Selected != nil { - return model, []Intent{{Kind: IntentLoadLogs, AgentID: model.Selected.ID}} - } + return model, []Intent{{Kind: IntentLoadLogs}} } return model, nil } func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) { - opts := ServerMenuOptions() + opts := ServerMenuOptions(model.LauncherRunning) switch key.Str { case "0": @@ -266,21 +268,28 @@ func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) { func executeServerAction(model Model, action string) (Model, []Intent) { switch action { - case "Start All": - model.StatusMsg = "Starting all agents..." - return model, []Intent{{Kind: IntentStartAll}} - case "Stop All": - model.StatusMsg = "Stopping all agents..." - return model, []Intent{{Kind: IntentStopAll}} - case "Restart All": - model.StatusMsg = "Restarting all agents..." - return model, []Intent{{Kind: IntentRestartAll}} - case "Kill All": - model.StatusMsg = "Killing all agents..." - return model, []Intent{{Kind: IntentKillAll}} + case "Start": + model.StatusMsg = "Starting launcher..." + return model, []Intent{{Kind: IntentStartLauncher}} + case "Stop": + model.StatusMsg = "Stopping launcher..." + return model, []Intent{{Kind: IntentStopLauncher}} + case "Restart": + model.StatusMsg = "Restarting launcher..." + return model, []Intent{{Kind: IntentRestartLauncher}} + case "Kill": + model.StatusMsg = "Killing launcher..." + return model, []Intent{{Kind: IntentKillLauncher}} case "Rebuild & Restart": model.StatusMsg = "Building & restarting..." return model, []Intent{{Kind: IntentRebuildRestart}} + case "Logs": + model.Screen = ScreenLogs + model.LogLines = nil + model.LogScroll = 0 + model.Selected = nil + model.Cursor = 0 + return model, []Intent{{Kind: IntentLoadLogs}} } return model, nil } @@ -288,7 +297,7 @@ func executeServerAction(model Model, action string) (Model, []Intent) { // ── pure helpers ───────────────────────────────────────────────────────── func visibleLogLines(m Model) int { - lines := m.WindowHeight - 6 // header + footer + lines := m.WindowHeight - 6 if lines < 5 { return 5 } diff --git a/pkg/tui/view.go b/pkg/tui/view.go index 7683f08..2a431c1 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -102,39 +102,26 @@ func viewAgentActions(m Model) string { } a := m.Selected - icon := "○ stopped" - if a.Running { - if a.Instances > 1 { - icon = fmt.Sprintf("● running %d instances", a.Instances) - } else { - icon = fmt.Sprintf("● running PID %d", a.PID) - } + var icon string + switch { + case !a.Enabled: + icon = " disabled" + case a.Running: + icon = "● enabled (running)" + default: + icon = "○ enabled (stopped)" } b.WriteString(fmt.Sprintf("\n %s %s\n", a.ID, icon)) b.WriteString(" " + strings.Repeat("─", 44) + "\n") - // Stats line if running - if a.Running && (a.Memory != "" || a.CPU != "") { - parts := []string{} - if a.Uptime != "" { - parts = append(parts, "uptime: "+a.Uptime) - } - if a.Memory != "" { - parts = append(parts, "mem: "+a.Memory) - } - if a.CPU != "" { - parts = append(parts, "cpu: "+a.CPU) - } - if a.LogSize != "" { - parts = append(parts, "log: "+a.LogSize) - } - b.WriteString(" " + strings.Join(parts, " ") + "\n") + if a.Desc != "" { + b.WriteString(" " + a.Desc + "\n") } b.WriteString("\n") - opts := AgentActionOptions(a.Running) + opts := AgentActionOptions(a.Enabled) for i, opt := range opts { cursor := " " if i == m.Cursor { @@ -154,7 +141,7 @@ func viewAgentActions(m Model) string { func viewLogs(m Model) string { var b strings.Builder - agentID := "?" + agentID := "Launcher" if m.Selected != nil { agentID = m.Selected.ID } @@ -190,27 +177,41 @@ func viewLogs(m Model) string { func viewServer(m Model) string { var b strings.Builder - b.WriteString("\n Server Management\n") + b.WriteString("\n Launcher Management\n") b.WriteString(" " + strings.Repeat("─", 44) + "\n") - // Summary - running, stopped, disabled := countStatuses(m.Agents) - total := len(m.Agents) - if total > 0 { - b.WriteString(fmt.Sprintf(" %d agents: %d running, %d stopped, %d disabled\n", total, running, stopped, disabled)) + // Launcher status + if m.LauncherRunning { + b.WriteString(fmt.Sprintf(" ● Launcher running PID %d\n", m.LauncherPID)) + parts := []string{} + if m.LauncherUptime != "" { + parts = append(parts, "uptime: "+m.LauncherUptime) + } + if m.LauncherMemory != "" { + parts = append(parts, "mem: "+m.LauncherMemory) + } + if m.LauncherCPU != "" { + parts = append(parts, "cpu: "+m.LauncherCPU) + } + if m.LauncherLogSize != "" { + parts = append(parts, "log: "+m.LauncherLogSize) + } + if len(parts) > 0 { + b.WriteString(" " + strings.Join(parts, " ") + "\n") + } } else { - b.WriteString(" Loading...\n") + b.WriteString(" ○ Launcher stopped\n") } - // Agent status list (compact) - if total > 0 { - b.WriteString("\n") + // Agent summary + _, _, disabled := countStatuses(m.Agents) + enabled := len(m.Agents) - disabled + if len(m.Agents) > 0 { + b.WriteString(fmt.Sprintf("\n %d agents (%d enabled, %d disabled)\n", len(m.Agents), enabled, disabled)) for _, a := range m.Agents { - icon := "○" + icon := "●" if !a.Enabled { - icon = " " - } else if a.Running { - icon = "●" + icon = "○" } b.WriteString(fmt.Sprintf(" %s %s\n", icon, a.ID)) } @@ -219,12 +220,12 @@ func viewServer(m Model) string { b.WriteString("\n") // Action menu - for i, opt := range ServerMenuOptions() { + for i, opt := range ServerMenuOptions(m.LauncherRunning) { cursor := " " if i == m.Cursor { cursor = "> " } - b.WriteString(fmt.Sprintf(" %s%-16s %s\n", cursor, opt.Label, opt.Desc)) + b.WriteString(fmt.Sprintf(" %s%-20s %s\n", cursor, opt.Label, opt.Desc)) } if m.StatusMsg != "" { diff --git a/shell/process/manager.go b/shell/process/manager.go index 29720b6..f21a2a8 100644 --- a/shell/process/manager.go +++ b/shell/process/manager.go @@ -82,6 +82,8 @@ func (osProber) isAlive(pid int) bool { return syscall.Kill(pid, 0) == nil } +const unifiedID = "launcher" // PID/log file ID for the unified launcher + // Manager handles agent process lifecycle. type Manager struct { runDir string @@ -388,6 +390,184 @@ func (m *Manager) Build() (string, error) { return string(out), err } +// ── Unified launcher ───────────────────────────────────────────────────── +// The unified launcher runs ALL enabled agents + orchestrator in a single +// process. PID → run/launcher.pid, log → run/launcher.log. + +// StartUnified launches the unified launcher (no -c flag → discovers all agents). +func (m *Manager) StartUnified() error { + if m.IsUnifiedRunning() { + return fmt.Errorf("unified launcher is already running (PID %d)", m.readPID(unifiedID)) + } + if err := os.MkdirAll(m.runDir, 0o755); err != nil { + return fmt.Errorf("create run dir: %w", err) + } + + logFile, err := os.OpenFile(m.logPath(unifiedID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open log: %w", err) + } + + bin := m.resolvedBin() + var cmd *exec.Cmd + if strings.HasPrefix(bin, "go run") { + cmd = exec.Command("go", "run", "-tags", "goolm", "./cmd/launcher", "--log-level", "info") + } else { + cmd = exec.Command(bin, "--log-level", "info") + } + + cmd.Env = m.buildEnv() + cmd.Stdout = logFile + cmd.Stderr = logFile + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := cmd.Start(); err != nil { + logFile.Close() + return fmt.Errorf("exec: %w", err) + } + + if err := os.WriteFile(m.pidPath(unifiedID), []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { + return fmt.Errorf("write PID: %w", err) + } + + go func() { _ = cmd.Wait() }() + return nil +} + +// StopUnified stops the unified launcher process. +func (m *Manager) StopUnified() error { + return m.Stop(unifiedID) +} + +// KillUnified sends SIGKILL to the unified launcher. +func (m *Manager) KillUnified() error { + return m.Kill(unifiedID) +} + +// IsUnifiedRunning checks if the unified launcher is alive. +func (m *Manager) IsUnifiedRunning() bool { + pid := m.readPID(unifiedID) + if pid > 0 && m.isAlive(pid) { + return true + } + // Fallback: search for launcher running without -c flag + pids := m.findUnifiedPIDs() + return len(pids) > 0 +} + +// UnifiedPID returns the PID of the running unified launcher, or 0. +func (m *Manager) UnifiedPID() int { + pid := m.readPID(unifiedID) + if pid > 0 && m.isAlive(pid) { + return pid + } + pids := m.findUnifiedPIDs() + if len(pids) > 0 { + // Repair PID file + _ = os.WriteFile(m.pidPath(unifiedID), []byte(strconv.Itoa(pids[0])), 0o644) + return pids[0] + } + return 0 +} + +// UnifiedStats returns resource usage for the unified launcher process. +func (m *Manager) UnifiedStats() (ProcessStats, error) { + pid := m.UnifiedPID() + if pid == 0 { + return ProcessStats{}, fmt.Errorf("unified launcher is not running") + } + return m.statsForPID(pid, unifiedID), nil +} + +// UnifiedLogTail returns the last N lines of the unified launcher log. +func (m *Manager) UnifiedLogTail(lines int) ([]string, error) { + return m.LogTail(unifiedID, lines) +} + +// StatusAllUnified returns status for all agents, deriving "running" from +// whether the unified launcher is running + the agent is enabled. +func (m *Manager) StatusAllUnified() ([]AgentStatus, error) { + agents, err := m.Scan() + if err != nil { + return nil, err + } + launcherRunning := m.IsUnifiedRunning() + launcherPID := m.UnifiedPID() + + statuses := make([]AgentStatus, len(agents)) + for i, a := range agents { + running := launcherRunning && a.Enabled + pid := 0 + instances := 0 + if running { + pid = launcherPID + instances = 1 + } + statuses[i] = AgentStatus{ + AgentInfo: a, + Running: running, + PID: pid, + Instances: instances, + } + } + return statuses, nil +} + +// ToggleEnabled sets the enabled field in an agent's config.yaml. +func (m *Manager) ToggleEnabled(id string, enabled bool) error { + agents, err := m.Scan() + if err != nil { + return err + } + for _, a := range agents { + if a.ID == id { + return m.setEnabledInConfig(a.ConfigPath, enabled) + } + } + return fmt.Errorf("agent %q not found", id) +} + +// setEnabledInConfig rewrites the enabled field in a config.yaml. +func (m *Manager) setEnabledInConfig(path string, enabled bool) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + val := "false" + if enabled { + val = "true" + } + + lines := strings.Split(string(data), "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "enabled:") { + // Preserve indentation + indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))] + lines[i] = indent + "enabled: " + val + break + } + } + + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644) +} + +// findUnifiedPIDs finds launcher processes running without -c flag. +func (m *Manager) findUnifiedPIDs() []int { + // Search for launcher processes that do NOT have -c flag + raw := m.prober.pgrepPIDs("launcher.*--log-level") + var pids []int + for _, p := range raw { + comm := m.prober.processComm(p) + if comm == "go" { + continue + } + pids = append(pids, p) + } + return pids +} + // ── internal helpers ───────────────────────────────────────────────────── func (m *Manager) pidPath(id string) string { return filepath.Join(m.runDir, id+".pid") } diff --git a/shell/tui/adapter.go b/shell/tui/adapter.go index 2e9cb27..4e257a0 100644 --- a/shell/tui/adapter.go +++ b/shell/tui/adapter.go @@ -30,32 +30,26 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd { case puretui.IntentLoadAgents: return a.loadAgents() - case puretui.IntentStartAgent: - return a.startAgent(intent.AgentID) + case puretui.IntentEnableAgent: + return a.enableAgent(intent.AgentID) - case puretui.IntentStopAgent: - return a.stopAgent(intent.AgentID) - - case puretui.IntentKillAgent: - return a.killAgent(intent.AgentID) - - case puretui.IntentRestartAgent: - return a.restartAgent(intent.AgentID) + case puretui.IntentDisableAgent: + return a.disableAgent(intent.AgentID) case puretui.IntentLoadLogs: return a.loadLogs(intent.AgentID) - case puretui.IntentStartAll: - return a.startAll() + case puretui.IntentStartLauncher: + return a.startLauncher() - case puretui.IntentStopAll: - return a.stopAll() + case puretui.IntentStopLauncher: + return a.stopLauncher() - case puretui.IntentRestartAll: - return a.restartAll() + case puretui.IntentRestartLauncher: + return a.restartLauncher() - case puretui.IntentKillAll: - return a.killAll() + case puretui.IntentKillLauncher: + return a.killLauncher() case puretui.IntentRebuildRestart: return a.rebuildRestart() @@ -73,287 +67,149 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd { func (a *Adapter) loadAgents() tea.Cmd { return func() tea.Msg { - statuses, err := a.mgr.StatusAll() + statuses, err := a.mgr.StatusAllUnified() if err != nil { return puretui.MsgAgentsLoaded{} } views := make([]puretui.AgentView, len(statuses)) for i, s := range statuses { - v := puretui.AgentView{ - ID: s.ID, - Name: s.Name, - Version: s.Version, - Desc: s.Desc, - Enabled: s.Enabled, - Running: s.Running, - PID: s.PID, - Instances: s.Instances, + views[i] = puretui.AgentView{ + ID: s.ID, + Name: s.Name, + Version: s.Version, + Desc: s.Desc, + Enabled: s.Enabled, + Running: s.Running, + PID: s.PID, } - - if s.Running { - if stats, err := a.mgr.Stats(s.ID); err == nil { - v.Uptime = formatUptime(stats.UptimeSecs) - v.Memory = formatBytes(stats.MemRSSKB * 1024) - v.CPU = fmt.Sprintf("%.1f%%", stats.CPUPct) - v.LogSize = formatBytes(stats.LogBytes) - } - } - - views[i] = v } - return puretui.MsgAgentsLoaded{Agents: views} + msg := puretui.MsgAgentsLoaded{ + Agents: views, + LauncherRunning: a.mgr.IsUnifiedRunning(), + LauncherPID: a.mgr.UnifiedPID(), + } + + // Launcher stats + if msg.LauncherRunning { + if stats, err := a.mgr.UnifiedStats(); err == nil { + msg.LauncherUptime = formatUptime(stats.UptimeSecs) + msg.LauncherMemory = formatBytes(stats.MemRSSKB * 1024) + msg.LauncherCPU = fmt.Sprintf("%.1f%%", stats.CPUPct) + msg.LauncherLogSize = formatBytes(stats.LogBytes) + } + } + + return msg } } -func (a *Adapter) startAgent(id string) tea.Cmd { +func (a *Adapter) enableAgent(id string) tea.Cmd { return func() tea.Msg { - agents, err := a.mgr.Scan() - if err != nil { - return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} - } - for _, agent := range agents { - if agent.ID == id { - err = a.mgr.Start(agent) - // Give the process a moment to start. - if err == nil { - time.Sleep(500 * time.Millisecond) - } - return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: err} - } - } - return puretui.MsgActionDone{AgentID: id, Action: "Start", Err: fmt.Errorf("agent not found")} + err := a.mgr.ToggleEnabled(id, true) + return puretui.MsgActionDone{AgentID: id, Action: "Enable", Err: err} } } -func (a *Adapter) stopAgent(id string) tea.Cmd { +func (a *Adapter) disableAgent(id string) tea.Cmd { return func() tea.Msg { - err := a.mgr.Stop(id) - return puretui.MsgActionDone{AgentID: id, Action: "Stop", Err: err} + err := a.mgr.ToggleEnabled(id, false) + return puretui.MsgActionDone{AgentID: id, Action: "Disable", Err: err} } } -func (a *Adapter) killAgent(id string) tea.Cmd { +func (a *Adapter) startLauncher() tea.Cmd { return func() tea.Msg { - err := a.mgr.Kill(id) - return puretui.MsgActionDone{AgentID: id, Action: "Kill", Err: err} - } -} - -func (a *Adapter) restartAgent(id string) tea.Cmd { - return func() tea.Msg { - // Stop first (ignore error if not running) - _ = a.mgr.Stop(id) - time.Sleep(300 * time.Millisecond) - - agents, err := a.mgr.Scan() - if err != nil { - return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} - } - for _, agent := range agents { - if agent.ID == id { - err = a.mgr.Start(agent) - if err == nil { - time.Sleep(500 * time.Millisecond) - } - return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err} - } - } - return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: fmt.Errorf("agent not found")} - } -} - -func (a *Adapter) startAll() tea.Cmd { - return func() tea.Msg { - agents, err := a.mgr.Scan() - if err != nil { - return puretui.MsgServerActionDone{Action: "Start All", Errors: []string{err.Error()}, Failed: 1} - } - var total, failed int - var errs []string - for _, agent := range agents { - if !agent.Enabled { - continue - } - if a.mgr.IsRunning(agent.ID) { - continue - } - total++ - if err := a.mgr.Start(agent); err != nil { - failed++ - errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err)) - } - } - if total > 0 { + err := a.mgr.StartUnified() + if err == nil { time.Sleep(500 * time.Millisecond) } - return puretui.MsgServerActionDone{Action: "Start All", Total: total, Failed: failed, Errors: errs} + return puretui.MsgServerActionDone{Action: "Start", Err: err} } } -func (a *Adapter) stopAll() tea.Cmd { +func (a *Adapter) stopLauncher() tea.Cmd { return func() tea.Msg { - statuses, err := a.mgr.StatusAll() - if err != nil { - return puretui.MsgServerActionDone{Action: "Stop All", Errors: []string{err.Error()}, Failed: 1} - } - var total, failed int - var errs []string - for _, s := range statuses { - if !s.Running { - continue - } - total++ - if err := a.mgr.Stop(s.ID); err != nil { - failed++ - errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err)) - } - } - return puretui.MsgServerActionDone{Action: "Stop All", Total: total, Failed: failed, Errors: errs} + err := a.mgr.StopUnified() + return puretui.MsgServerActionDone{Action: "Stop", Err: err} } } -func (a *Adapter) restartAll() tea.Cmd { +func (a *Adapter) restartLauncher() tea.Cmd { return func() tea.Msg { - agents, err := a.mgr.Scan() - if err != nil { - return puretui.MsgServerActionDone{Action: "Restart All", Errors: []string{err.Error()}, Failed: 1} - } - - // Stop all running first - for _, agent := range agents { - if agent.Enabled && a.mgr.IsRunning(agent.ID) { - _ = a.mgr.Stop(agent.ID) - } - } - time.Sleep(300 * time.Millisecond) - - // Start all enabled - var total, failed int - var errs []string - for _, agent := range agents { - if !agent.Enabled { - continue - } - total++ - if err := a.mgr.Start(agent); err != nil { - failed++ - errs = append(errs, fmt.Sprintf("%s: %v", agent.ID, err)) - } - } - if total > 0 { + _ = a.mgr.StopUnified() + time.Sleep(500 * time.Millisecond) + err := a.mgr.StartUnified() + if err == nil { time.Sleep(500 * time.Millisecond) } - return puretui.MsgServerActionDone{Action: "Restart All", Total: total, Failed: failed, Errors: errs} + return puretui.MsgServerActionDone{Action: "Restart", Err: err} } } -func (a *Adapter) killAll() tea.Cmd { +func (a *Adapter) killLauncher() tea.Cmd { return func() tea.Msg { - statuses, err := a.mgr.StatusAll() - if err != nil { - return puretui.MsgServerActionDone{Action: "Kill All", Errors: []string{err.Error()}, Failed: 1} - } - var total, failed int - var errs []string - for _, s := range statuses { - if !s.Running { - continue - } - total++ - if err := a.mgr.Kill(s.ID); err != nil { - failed++ - errs = append(errs, fmt.Sprintf("%s: %v", s.ID, err)) - } - } - return puretui.MsgServerActionDone{Action: "Kill All", Total: total, Failed: failed, Errors: errs} + err := a.mgr.KillUnified() + return puretui.MsgServerActionDone{Action: "Kill", Err: err} } } func (a *Adapter) rebuildRestart() tea.Cmd { return func() tea.Msg { - // 1. Remember which agents are currently running - statuses, err := a.mgr.StatusAll() - if err != nil { - return puretui.MsgRebuildDone{Errors: []string{err.Error()}} - } - var wasRunning []string - for _, s := range statuses { - if s.Running { - wasRunning = append(wasRunning, s.ID) - } - } + wasRunning := a.mgr.IsUnifiedRunning() - // 2. Stop all running agents - for _, id := range wasRunning { - _ = a.mgr.Stop(id) - } - if len(wasRunning) > 0 { + // Stop if running + if wasRunning { + _ = a.mgr.StopUnified() time.Sleep(500 * time.Millisecond) } - // 3. Build + // Build buildOut, buildErr := a.mgr.Build() if buildErr != nil { - // Build failed — try to restart what was running before - for _, id := range wasRunning { - agents, _ := a.mgr.Scan() - for _, ag := range agents { - if ag.ID == id { - _ = a.mgr.Start(ag) - break - } - } + // Build failed — try to restart if was running + if wasRunning { + _ = a.mgr.StartUnified() } - // Return last lines of build output lines := strings.Split(strings.TrimSpace(buildOut), "\n") tail := buildOut if len(lines) > 5 { tail = strings.Join(lines[len(lines)-5:], "\n") } - return puretui.MsgRebuildDone{BuildLog: tail} + return puretui.MsgRebuildDone{BuildOK: false, BuildLog: tail} } - // 4. Restart only the agents that were running before - agents, _ := a.mgr.Scan() - agentMap := make(map[string]process.AgentInfo) - for _, ag := range agents { - agentMap[ag.ID] = ag - } - - var restarted, failed int - var errs []string - for _, id := range wasRunning { - ag, ok := agentMap[id] - if !ok { - failed++ - errs = append(errs, fmt.Sprintf("%s: not found after build", id)) - continue + // Restart launcher + started := false + var startErr error + if wasRunning { + startErr = a.mgr.StartUnified() + if startErr == nil { + started = true + time.Sleep(500 * time.Millisecond) } - if err := a.mgr.Start(ag); err != nil { - failed++ - errs = append(errs, fmt.Sprintf("%s: %v", id, err)) - } else { - restarted++ - } - } - if restarted > 0 { - time.Sleep(500 * time.Millisecond) } return puretui.MsgRebuildDone{ - BuildOK: true, - Restarted: restarted, - Failed: failed, - Errors: errs, + BuildOK: true, + Started: started, + Err: startErr, } } } func (a *Adapter) loadLogs(id string) tea.Cmd { return func() tea.Msg { - lines, err := a.mgr.LogTail(id, 100) + var lines []string + var err error + if id == "" { + // Launcher logs + lines, err = a.mgr.UnifiedLogTail(100) + } else { + // Agent logs — in unified mode, all go to launcher log + lines, err = a.mgr.UnifiedLogTail(100) + } if err != nil { return puretui.MsgLogsLoaded{Lines: []string{"Error: " + err.Error()}} }