Files
agents_and_robots/shell/tui/adapter.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

420 lines
10 KiB
Go

// Package tui is the impure shell layer for the TUI.
// It converts pure Intent values into real I/O via tea.Cmd.
package tui
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
puretui "github.com/enmanuel/agents/pkg/tui"
"github.com/enmanuel/agents/shell/process"
)
// Adapter bridges pure Intents with the process Manager.
type Adapter struct {
mgr *process.Manager
}
// NewAdapter creates an Adapter with the given Manager.
func NewAdapter(mgr *process.Manager) *Adapter {
return &Adapter{mgr: mgr}
}
// RunIntent converts a pure Intent into a bubbletea Cmd that performs I/O.
func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
switch intent.Kind {
case puretui.IntentLoadAgents:
return a.loadAgents()
case puretui.IntentEnableAgent:
return a.enableAgent(intent.AgentID)
case puretui.IntentDisableAgent:
return a.disableAgent(intent.AgentID)
case puretui.IntentReloadAgent:
return a.reloadAgent(intent.AgentID)
case puretui.IntentReloadAll:
return a.reloadAll()
case puretui.IntentRestartAgent:
return a.restartAgent(intent.AgentID)
case puretui.IntentLoadLogs:
return a.loadLogs(intent.AgentID)
case puretui.IntentStartLauncher:
return a.startLauncher()
case puretui.IntentStopLauncher:
return a.stopLauncher()
case puretui.IntentRestartLauncher:
return a.restartLauncher()
case puretui.IntentKillLauncher:
return a.killLauncher()
case puretui.IntentRebuildRestart:
return a.rebuildRestart()
case puretui.IntentRunTests:
return a.runGoTests()
case puretui.IntentRunGoTests:
return a.runGoTests()
case puretui.IntentRunE2ETests:
return a.runE2ETests(false)
case puretui.IntentRunE2EHeadTests:
return a.runE2ETests(true)
case puretui.IntentRunAllTests:
return a.runAllTests()
case puretui.IntentTick:
return a.tick()
case puretui.IntentQuit:
return tea.Quit
default:
return nil
}
}
func (a *Adapter) loadAgents() tea.Cmd {
return func() tea.Msg {
statuses, err := a.mgr.StatusAllUnified()
if err != nil {
return puretui.MsgAgentsLoaded{}
}
views := make([]puretui.AgentView, len(statuses))
for i, s := range statuses {
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,
}
}
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) enableAgent(id string) tea.Cmd {
return func() tea.Msg {
err := a.mgr.ToggleEnabled(id, true)
return puretui.MsgActionDone{AgentID: id, Action: "Enable", Err: err}
}
}
func (a *Adapter) disableAgent(id string) tea.Cmd {
return func() tea.Msg {
err := a.mgr.ToggleEnabled(id, false)
return puretui.MsgActionDone{AgentID: id, Action: "Disable", Err: err}
}
}
// reloadAgent hot-reloads a single agent via SIGHUP without stopping the launcher.
func (a *Adapter) reloadAgent(id string) tea.Cmd {
return func() tea.Msg {
pid := a.mgr.UnifiedPID()
if pid <= 0 {
return puretui.MsgActionDone{AgentID: id, Action: "Reload",
Err: fmt.Errorf("el launcher no está corriendo")}
}
if id != "" {
if err := os.WriteFile("run/reload.txt", []byte(id), 0o644); err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err}
}
}
err := syscall.Kill(pid, syscall.SIGHUP)
if err != nil {
return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: err}
}
time.Sleep(1 * time.Second)
return puretui.MsgActionDone{AgentID: id, Action: "Reload", Err: nil}
}
}
// reloadAll hot-reloads all agents via SIGHUP (no reload.txt → reload all).
func (a *Adapter) reloadAll() tea.Cmd {
return func() tea.Msg {
pid := a.mgr.UnifiedPID()
if pid <= 0 {
return puretui.MsgServerActionDone{Action: "Reload All",
Err: fmt.Errorf("el launcher no está corriendo")}
}
// Remove stale reload.txt so the launcher reloads all agents.
_ = os.Remove("run/reload.txt")
err := syscall.Kill(pid, syscall.SIGHUP)
if err != nil {
return puretui.MsgServerActionDone{Action: "Reload All", Err: err}
}
time.Sleep(1 * time.Second)
return puretui.MsgServerActionDone{Action: "Reload All", Err: nil}
}
}
// restartAgent stops and restarts the whole launcher (full restart, all agents).
func (a *Adapter) restartAgent(id string) tea.Cmd {
return func() tea.Msg {
_ = a.mgr.StopUnified()
time.Sleep(500 * time.Millisecond)
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgActionDone{AgentID: id, Action: "Restart", Err: err}
}
}
func (a *Adapter) startLauncher() tea.Cmd {
return func() tea.Msg {
err := a.mgr.StartUnified()
if err == nil {
time.Sleep(500 * time.Millisecond)
}
return puretui.MsgServerActionDone{Action: "Start", Err: err}
}
}
func (a *Adapter) stopLauncher() tea.Cmd {
return func() tea.Msg {
err := a.mgr.StopUnified()
return puretui.MsgServerActionDone{Action: "Stop", Err: err}
}
}
func (a *Adapter) restartLauncher() tea.Cmd {
return func() tea.Msg {
_ = 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", Err: err}
}
}
func (a *Adapter) killLauncher() tea.Cmd {
return func() tea.Msg {
err := a.mgr.KillUnified()
return puretui.MsgServerActionDone{Action: "Kill", Err: err}
}
}
func (a *Adapter) rebuildRestart() tea.Cmd {
return func() tea.Msg {
wasRunning := a.mgr.IsUnifiedRunning()
// Stop if running
if wasRunning {
_ = a.mgr.StopUnified()
time.Sleep(500 * time.Millisecond)
}
// Build
buildOut, buildErr := a.mgr.Build()
if buildErr != nil {
// Build failed — try to restart if was running
if wasRunning {
_ = a.mgr.StartUnified()
}
lines := strings.Split(strings.TrimSpace(buildOut), "\n")
tail := buildOut
if len(lines) > 5 {
tail = strings.Join(lines[len(lines)-5:], "\n")
}
return puretui.MsgRebuildDone{BuildOK: false, BuildLog: tail}
}
// Restart launcher
started := false
var startErr error
if wasRunning {
startErr = a.mgr.StartUnified()
if startErr == nil {
started = true
time.Sleep(500 * time.Millisecond)
}
}
return puretui.MsgRebuildDone{
BuildOK: true,
Started: started,
Err: startErr,
}
}
}
func (a *Adapter) loadLogs(id string) tea.Cmd {
return func() tea.Msg {
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()}}
}
return puretui.MsgLogsLoaded{Lines: lines}
}
}
func (a *Adapter) runGoTests() tea.Cmd {
return func() tea.Msg {
goBin, err := exec.LookPath("go")
if err != nil {
goBin = "/usr/local/go/bin/go"
}
cmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...")
cmd.Env = a.mgr.BuildEnv()
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if output == "" && err != nil {
output = "Error: " + err.Error()
}
lines := strings.Split(output, "\n")
return puretui.MsgTestsDone{Kind: puretui.TestKindGo, Passed: err == nil, Output: lines}
}
}
func (a *Adapter) runE2ETests(headed bool) tea.Cmd {
return func() tea.Msg {
args := []string{"./dev-scripts/e2e/run.sh"}
if headed {
args = append(args, "--headed")
}
cmd := exec.Command("bash", args...)
cmd.Env = a.mgr.BuildEnv()
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if output == "" && err != nil {
output = "Error: " + err.Error()
}
lines := strings.Split(output, "\n")
kind := puretui.TestKindE2E
if headed {
kind = puretui.TestKindE2EHead
}
return puretui.MsgTestsDone{Kind: kind, Passed: err == nil, Output: lines}
}
}
func (a *Adapter) runAllTests() tea.Cmd {
return func() tea.Msg {
var allLines []string
// Go tests first
goBin, err := exec.LookPath("go")
if err != nil {
goBin = "/usr/local/go/bin/go"
}
goCmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...")
goCmd.Env = a.mgr.BuildEnv()
goOut, goErr := goCmd.CombinedOutput()
allLines = append(allLines, "═══ Go Tests ═══")
goOutput := strings.TrimSpace(string(goOut))
if goOutput == "" && goErr != nil {
goOutput = "Error: " + goErr.Error()
}
allLines = append(allLines, strings.Split(goOutput, "\n")...)
if goErr != nil {
allLines = append(allLines, "", "Go tests FAILED — skipping E2E")
return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: false, Output: allLines}
}
// E2E tests
allLines = append(allLines, "", "═══ E2E Tests ═══")
e2eCmd := exec.Command("bash", "./dev-scripts/e2e/run.sh")
e2eCmd.Env = a.mgr.BuildEnv()
e2eOut, e2eErr := e2eCmd.CombinedOutput()
e2eOutput := strings.TrimSpace(string(e2eOut))
if e2eOutput == "" && e2eErr != nil {
e2eOutput = "Error: " + e2eErr.Error()
}
allLines = append(allLines, strings.Split(e2eOutput, "\n")...)
return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: e2eErr == nil, Output: allLines}
}
}
func (a *Adapter) tick() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return puretui.MsgTick{}
})
}
// ── formatting helpers ───────────────────────────────────────────────────
func formatUptime(secs int64) string {
if secs < 0 {
return "n/a"
}
d := secs / 86400
h := (secs % 86400) / 3600
m := (secs % 3600) / 60
if d > 0 {
return fmt.Sprintf("%dd %dh", d, h)
}
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}
func formatBytes(bytes int64) string {
switch {
case bytes >= 1<<30:
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(1<<30))
case bytes >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(1<<20))
case bytes >= 1<<10:
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(1<<10))
default:
return fmt.Sprintf("%d B", bytes)
}
}