f459d4e255
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>
466 lines
12 KiB
Go
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
|
|
}
|