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>
420 lines
10 KiB
Go
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)
|
|
}
|
|
}
|