Files
agents_and_robots/pkg/tui/update.go
T
egutierrez f459d4e255 feat: controles de hot-reload por agente en el dashboard TUI
Añade opciones de Reload (hot-reload) separadas de Restart (reinicio
completo) en el dashboard, usando el mecanismo SIGHUP implementado en
el issue 0013.

Cambios en pkg/tui/ (capa pura):
- IntentReloadAgent: hot-reload de un agente individual via SIGHUP
- IntentReloadAll: hot-reload de todos los agentes via SIGHUP
- AgentActionOptions: añade "Reload" antes de "Restart" con descripciones
  clarificadas ("sin interrumpir los demás" vs "launcher completo")
- ServerMenuOptions (running): añade "Reload All" como primera opción
- executeAction: maneja "Reload" → IntentReloadAgent
- executeServerAction: maneja "Reload All" → IntentReloadAll
- Mensajes de estado diferenciados: "Reload OK — X recargado sin
  interrupciones" vs "Restart OK — launcher reiniciado"

Cambios en shell/tui/ (capa impura):
- reloadAgent(id): escribe run/reload.txt + SIGHUP; error si launcher
  no está corriendo (no hay fallback a full restart)
- reloadAll(): elimina reload.txt + SIGHUP; error si no está corriendo
- restartAgent(id): restaurado a su comportamiento original de
  stop+start completo del launcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:49:00 +00:00

466 lines
12 KiB
Go

package tui
import "fmt"
// IntentKind represents a side effect the shell must perform.
type IntentKind string
const (
IntentLoadAgents IntentKind = "load_agents"
IntentLoadLogs IntentKind = "load_logs"
IntentTick IntentKind = "tick"
IntentQuit IntentKind = "quit"
// Agent-level
IntentEnableAgent IntentKind = "enable_agent"
IntentDisableAgent IntentKind = "disable_agent"
IntentReloadAgent IntentKind = "reload_agent" // hot-reload via SIGHUP (solo este agente)
IntentReloadAll IntentKind = "reload_all" // hot-reload via SIGHUP (todos los agentes)
IntentRestartAgent IntentKind = "restart_agent" // restart completo del launcher
// Unified launcher operations
IntentStartLauncher IntentKind = "start_launcher"
IntentStopLauncher IntentKind = "stop_launcher"
IntentRestartLauncher IntentKind = "restart_launcher"
IntentKillLauncher IntentKind = "kill_launcher"
IntentRebuildRestart IntentKind = "rebuild_restart"
IntentRunTests IntentKind = "run_tests"
IntentRunGoTests IntentKind = "run_go_tests"
IntentRunE2ETests IntentKind = "run_e2e_tests"
IntentRunE2EHeadTests IntentKind = "run_e2e_head_tests"
IntentRunAllTests IntentKind = "run_all_tests"
)
// Intent is pure data describing a side effect to execute.
type Intent struct {
Kind IntentKind
AgentID string
}
// 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.
}
// WindowSizeMsg carries terminal dimensions.
type WindowSizeMsg struct {
Width int
Height int
}
// Update is PURE: (Model, msg) → (Model, []Intent). No side effects.
func Update(model Model, msg interface{}) (Model, []Intent) {
switch m := msg.(type) {
case WindowSizeMsg:
model.WindowWidth = m.Width
model.WindowHeight = m.Height
return model, nil
case MsgAgentsLoaded:
model.Agents = m.Agents
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
}
}
return model, []Intent{{Kind: IntentTick}}
case MsgActionDone:
if m.Err != nil {
model.StatusMsg = fmt.Sprintf("Error: %s %s: %v", m.Action, m.AgentID, m.Err)
} else if m.Action == "Reload" {
model.StatusMsg = fmt.Sprintf("Reload OK — %s recargado sin interrupciones", m.AgentID)
} else if m.Action == "Restart" {
model.StatusMsg = "Restart OK — launcher reiniciado"
} else {
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 if m.Action == "Reload All" {
model.StatusMsg = "Reload All OK — SIGHUP enviado al launcher"
} 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.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 = "Built OK"
}
return model, []Intent{{Kind: IntentLoadAgents}}
case MsgLogsLoaded:
model.LogLines = m.Lines
model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model))
return model, nil
case MsgTestsDone:
model.Screen = ScreenTestOutput
model.LogLines = m.Output
model.LogScroll = 0
model.Cursor = 0
model.LastTestKind = m.Kind
label := testKindLabel(m.Kind)
if m.Passed {
model.StatusMsg = label + " PASSED"
} else {
model.StatusMsg = label + " FAILED"
}
return model, nil
case MsgTick:
return model, []Intent{{Kind: IntentLoadAgents}}
case KeyMsg:
return updateKey(model, m)
}
return model, nil
}
func updateKey(model Model, key KeyMsg) (Model, []Intent) {
if key.Str == "q" && model.Screen == ScreenMain {
return model, []Intent{{Kind: IntentQuit}}
}
if key.Str == "ctrl+c" {
return model, []Intent{{Kind: IntentQuit}}
}
switch model.Screen {
case ScreenMain:
return updateMainScreen(model, key)
case ScreenAgentList:
return updateAgentList(model, key)
case ScreenAgentActions:
return updateAgentActions(model, key)
case ScreenLogs:
return updateLogs(model, key)
case ScreenServer:
return updateServerScreen(model, key)
case ScreenTests:
return updateTestsScreen(model, key)
case ScreenTestOutput:
return updateTestOutput(model, key)
}
return model, nil
}
func updateMainScreen(model Model, key KeyMsg) (Model, []Intent) {
opts := MainMenuOptions()
switch key.Str {
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
switch opts[model.Cursor].Label {
case "Agents":
model.Screen = ScreenAgentList
model.Cursor = 0
return model, []Intent{{Kind: IntentLoadAgents}}
case "Server":
model.Screen = ScreenServer
model.Cursor = 0
model.StatusMsg = ""
return model, []Intent{{Kind: IntentLoadAgents}}
case "Tests":
model.Screen = ScreenTests
model.Cursor = 0
model.StatusMsg = ""
return model, nil
case "Quit":
return model, []Intent{{Kind: IntentQuit}}
}
}
return model, nil
}
func updateAgentList(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenMain
model.Cursor = 0
model.StatusMsg = ""
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, max(0, len(model.Agents)-1))
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, max(0, len(model.Agents)-1))
case "enter":
if model.Cursor < len(model.Agents) {
sel := model.Agents[model.Cursor]
model.Selected = &sel
model.Screen = ScreenAgentActions
model.Cursor = 0
model.StatusMsg = ""
}
}
return model, nil
}
func updateAgentActions(model Model, key KeyMsg) (Model, []Intent) {
if model.Selected == nil {
model.Screen = ScreenAgentList
return model, nil
}
opts := AgentActionOptions(model.Selected.Enabled)
switch key.Str {
case "0":
model.Screen = ScreenAgentList
model.Cursor = 0
model.Selected = nil
model.StatusMsg = ""
return model, []Intent{{Kind: IntentLoadAgents}}
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
if model.Cursor < len(opts) {
return executeAction(model, opts[model.Cursor].Label)
}
}
return model, nil
}
func executeAction(model Model, action string) (Model, []Intent) {
id := model.Selected.ID
switch action {
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 "Reload":
model.StatusMsg = "Hot-reloading " + id + "..."
return model, []Intent{{Kind: IntentReloadAgent, AgentID: id}}
case "Restart":
model.StatusMsg = "Restarting launcher (all agents)..."
return model, []Intent{{Kind: IntentRestartAgent, AgentID: id}}
case "Logs":
model.Screen = ScreenLogs
model.LogLines = nil
model.LogScroll = 0
model.Cursor = 0
return model, []Intent{{Kind: IntentLoadLogs, AgentID: id}}
}
return model, nil
}
func updateLogs(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
if model.Selected != nil {
model.Screen = ScreenAgentActions
} else {
model.Screen = ScreenServer
}
model.Cursor = 0
model.LogLines = nil
model.LogScroll = 0
case "up", "k":
model.LogScroll = max(0, model.LogScroll-1)
case "down", "j":
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
model.LogScroll = min(model.LogScroll+1, maxScroll)
case "r":
return model, []Intent{{Kind: IntentLoadLogs}}
}
return model, nil
}
func updateServerScreen(model Model, key KeyMsg) (Model, []Intent) {
opts := ServerMenuOptions(model.LauncherRunning)
switch key.Str {
case "0":
model.Screen = ScreenMain
model.Cursor = 0
model.StatusMsg = ""
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
if model.Cursor < len(opts) {
return executeServerAction(model, opts[model.Cursor].Label)
}
}
return model, nil
}
func executeServerAction(model Model, action string) (Model, []Intent) {
switch action {
case "Reload All":
model.StatusMsg = "Hot-reloading all agents..."
return model, []Intent{{Kind: IntentReloadAll}}
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 "Tests":
model.Screen = ScreenTests
model.Cursor = 0
model.StatusMsg = ""
return model, nil
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
}
func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenTests
model.Cursor = 0
model.LogLines = nil
model.LogScroll = 0
model.StatusMsg = ""
case "up", "k":
model.LogScroll = max(0, model.LogScroll-1)
case "down", "j":
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
model.LogScroll = min(model.LogScroll+1, maxScroll)
case "r":
intent := testKindIntent(model.LastTestKind)
if intent == "" {
intent = IntentRunGoTests
}
model.StatusMsg = "Running tests..."
model.LogLines = nil
model.LogScroll = 0
return model, []Intent{{Kind: intent}}
}
return model, nil
}
func updateTestsScreen(model Model, key KeyMsg) (Model, []Intent) {
opts := TestMenuOptions()
switch key.Str {
case "0":
model.Screen = ScreenMain
model.Cursor = 0
model.StatusMsg = ""
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
if model.Cursor < len(opts) {
return executeTestAction(model, opts[model.Cursor].Label)
}
}
return model, nil
}
func executeTestAction(model Model, action string) (Model, []Intent) {
model.StatusMsg = "Running tests..."
model.LogLines = nil
model.LogScroll = 0
switch action {
case "Go Tests":
model.LastTestKind = TestKindGo
return model, []Intent{{Kind: IntentRunGoTests}}
case "E2E Tests":
model.LastTestKind = TestKindE2E
return model, []Intent{{Kind: IntentRunE2ETests}}
case "E2E Tests (headed)":
model.LastTestKind = TestKindE2EHead
return model, []Intent{{Kind: IntentRunE2EHeadTests}}
case "All Tests":
model.LastTestKind = TestKindAll
return model, []Intent{{Kind: IntentRunAllTests}}
}
return model, nil
}
// testKindIntent maps a TestKind to its corresponding IntentKind.
func testKindIntent(k TestKind) IntentKind {
switch k {
case TestKindGo:
return IntentRunGoTests
case TestKindE2E:
return IntentRunE2ETests
case TestKindE2EHead:
return IntentRunE2EHeadTests
case TestKindAll:
return IntentRunAllTests
default:
return ""
}
}
// testKindLabel returns a human-readable label for a TestKind.
func testKindLabel(k TestKind) string {
switch k {
case TestKindGo:
return "Go Tests"
case TestKindE2E:
return "E2E Tests"
case TestKindE2EHead:
return "E2E Tests (headed)"
case TestKindAll:
return "All Tests"
default:
return "Tests"
}
}
// ── pure helpers ─────────────────────────────────────────────────────────
func visibleLogLines(m Model) int {
lines := m.WindowHeight - 6
if lines < 5 {
return 5
}
return lines
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}