diff --git a/client/restart.go b/client/restart.go new file mode 100644 index 00000000..b1f2f7ba --- /dev/null +++ b/client/restart.go @@ -0,0 +1,30 @@ +package client + +import "github.com/enowx/enowx/proto" + +// RestartPlan says what replacing the daemon would stop now. A daemon too old to say is +// judged by its sessions, every terminal among them as it can't tell what runs there. +func (c *Client) RestartPlan() (proto.RestartPlan, error) { + var plan proto.RestartPlan + err := c.Call(proto.MethodDaemonRestartPlan, nil, &plan) + if !IsUnknownMethod(err) { + return plan, err + } + list, err := c.Sessions() + if err != nil { + return proto.RestartPlan{}, err + } + plan = proto.RestartPlan{Unknown: true} + for _, s := range list { + item := proto.RestartItem{Session: s.ID, Name: s.Name, What: s.Agent} + switch { + case s.Exited || s.Queued: + case s.Agent == "" || s.Agent == "shell": + item.What = "a terminal: this daemon can't say what runs in it" + plan.Commands = append(plan.Commands, item) + case (s.State == proto.StateWorking && !s.StateStale) || s.State == proto.StateWaiting: + plan.Agents = append(plan.Agents, item) + } + } + return plan, nil +} diff --git a/core/agent/tabrestart.go b/core/agent/tabrestart.go new file mode 100644 index 00000000..a61531e5 --- /dev/null +++ b/core/agent/tabrestart.go @@ -0,0 +1,71 @@ +package agent + +import ( + "path/filepath" + "slices" + "strings" +) + +// Flags kept of an agent found running in a terminal when it is brought back: its mode, +// model and permissions. Anything else may be a prompt, already sent, or a secret. +var ( + tabValueFlags = []string{"--model", "-m", "--permission-mode", "--effort", "--settings", "--mcp-config", + "--append-system-prompt-file", "--add-dir", "--resume", "-r", "--sandbox", "-s", "--ask-for-approval", "-a"} + tabBareFlags = []string{"--dangerously-skip-permissions", "--allow-dangerously-skip-permissions", + "--dangerously-bypass-approvals-and-sandbox", "--yolo", "--full-auto"} + // tabOneShot marks a run that does its work and ends: nothing to bring back. + tabOneShot = []string{"-p", "--print", "exec", "run"} +) + +// TabAgentCommand is the command bringing back the agent running as args in a terminal, +// on its conversation and in its mode; false for what isn't an agent enowX can continue, +// which ends with the terminal. It never holds a prompt. +func TabAgentCommand(args []string) ([]string, bool) { + if len(args) == 0 { + return nil, false + } + rest := args[1:] + preset, ok := findByProgram(args[0]) + if !ok && len(args) > 1 && slices.Contains([]string{"node", "bun", "deno", "sh", "bash", "python3"}, strings.TrimSuffix(filepath.Base(args[0]), ".exe")) { + preset, ok = findByProgram(args[1]) + rest = args[2:] + } + if !ok || len(preset.Continue) == 0 { + return nil, false + } + var kept []string + resumes := false + for i := 0; i < len(rest); i++ { + flag, value, hasValue := strings.Cut(rest[i], "=") + switch { + case slices.Contains(tabOneShot, flag): + return nil, false + case slices.Contains(tabBareFlags, flag): + kept = append(kept, flag) + case slices.Contains(tabValueFlags, flag): + resumes = resumes || flag == "--resume" || flag == "-r" + if hasValue { + kept = append(kept, flag, value) + } else if i+1 < len(rest) { + kept = append(kept, flag, rest[i+1]) + i++ + } + } + } + out := []string{preset.Command} + if !resumes { + out = append(out, preset.Continue...) // first: some are subcommands + } + return append(out, kept...), true +} + +// findByProgram is the preset whose command program runs. +func findByProgram(program string) (Preset, bool) { + base := strings.TrimSuffix(strings.TrimSuffix(filepath.Base(program), ".exe"), ".js") + for _, p := range Presets { + if p.Command == base { + return p, true + } + } + return Preset{}, false +} diff --git a/core/agent/tabrestart_test.go b/core/agent/tabrestart_test.go new file mode 100644 index 00000000..f451d4d2 --- /dev/null +++ b/core/agent/tabrestart_test.go @@ -0,0 +1,34 @@ +package agent + +import ( + "slices" + "strings" + "testing" +) + +func TestTabAgentCommand(t *testing.T) { + for _, c := range []struct { + name string + args []string + want []string + ok bool + }{ + {"keeps its mode and model, never its prompt", []string{"/usr/local/bin/claude", "--model", "opus", "--permission-mode=plan", "--dangerously-skip-permissions", "fix the bug"}, + []string{"claude", "--continue", "--model", "opus", "--permission-mode", "plan", "--dangerously-skip-permissions"}, true}, + {"its own conversation", []string{"claude", "--resume", "abc-123"}, []string{"claude", "--resume", "abc-123"}, true}, + {"through node", []string{"node", "/opt/bin/claude", "--effort", "high"}, []string{"claude", "--continue", "--effort", "high"}, true}, + {"a secret stays out", []string{"claude", "--api-key", "sk-ant-abcdefgh"}, []string{"claude", "--continue"}, true}, + {"one shot", []string{"claude", "-p", "write the plan"}, nil, false}, + {"one shot of Codex", []string{"codex", "exec", "write the plan"}, nil, false}, + {"not an agent", []string{"npm", "run", "dev"}, nil, false}, + {"nothing", nil, nil, false}, + } { + got, ok := TabAgentCommand(c.args) + if ok != c.ok || !slices.Equal(got, c.want) { + t.Errorf("%s: %q, %v; want %q", c.name, got, ok, c.want) + } + if strings.Contains(strings.Join(got, " "), "sk-ant") || strings.Contains(strings.Join(got, " "), "fix the bug") { + t.Errorf("%s: kept %q", c.name, got) + } + } +} diff --git a/daemon/logs.go b/daemon/logs.go index 3d26a894..fc166f93 100644 --- a/daemon/logs.go +++ b/daemon/logs.go @@ -14,6 +14,7 @@ const logsSweepEvery = 6 * time.Hour func init() { slowMethods[proto.MethodDaemonDiagnostics] = true // it reads files + slowMethods[proto.MethodDaemonRestartPlan] = true // it samples processes } // daemonLogWriter is where the daemon's logger writes: its log, and its output too diff --git a/daemon/persist.go b/daemon/persist.go index c39b8995..fc338c74 100644 --- a/daemon/persist.go +++ b/daemon/persist.go @@ -27,11 +27,16 @@ const ( // record is a session as saved on disk. type record struct { - ID string `json:"id"` - Created time.Time `json:"created"` - Dir string `json:"dir"` - Command []string `json:"command,omitempty"` - Relaunch bool `json:"relaunch,omitempty"` + ID string `json:"id"` + Created time.Time `json:"created"` + Dir string `json:"dir"` + Command []string `json:"command,omitempty"` + Relaunch bool `json:"relaunch,omitempty"` + // What the daemon's stopping noted, for the next one alone: an update of the record + // forgets it, as it no longer holds. + MidTurn bool `json:"midTurn,omitempty"` // its agent was at work + TabAgent []string `json:"tabAgent,omitempty"` // an agent run by hand in its terminal, to bring back + Lost []string `json:"lost,omitempty"` // what ran in its terminal, which can't come back Restart []string `json:"restart,omitempty"` // nil with Relaunch: the login shell Convo string `json:"conversation,omitempty"` // Meta.Conversation Exited bool `json:"exited,omitempty"` @@ -196,6 +201,26 @@ func (p *persistence) loadHistory(id string) []byte { // stopping marks the daemon as stopping: the sessions it hangs up next are // relaunched at the next start, not recorded as exited. +// restartNote is what a daemon's stopping notes of a session. +type restartNote struct { + midTurn bool + tabAgent []string + lost []string +} + +// noteRestart keeps note in session id's record, for the next daemon. +func (p *persistence) noteRestart(id string, note restartNote) { + p.mu.Lock() + defer p.mu.Unlock() + r, known := p.records[id] + if !known || p.closing { + return + } + r.MidTurn, r.TabAgent, r.Lost = note.midTurn, note.tabAgent, note.lost + p.records[id] = r + p.saveLocked() +} + func (p *persistence) stopping() { p.mu.Lock() p.closing = true @@ -245,6 +270,10 @@ func (s *Server) restore() { Hibernated: r.Hibernated, HibernatedByHand: r.HibernatedByHand, HibernatedAt: r.HibernatedAt, NoAutoHibernate: r.NoAutoHibernate, }, } + for _, what := range r.Lost { + line := "\r\n\x1b[33m── This was running here, and ended when the daemon restarted: " + what + " ──\x1b[0m" + spec.History = append(spec.History, line...) + } _, dirErr := os.Stat(r.Dir) if r.Exited || !r.Relaunch || dirErr != nil { s.exitsLogged.Store(r.ID, true) // ended with an earlier daemon @@ -283,6 +312,10 @@ func (s *Server) restore() { spec.Command = thenShell(preset.Argv(l)) spec.Meta.Restart = spec.Command } + if len(r.TabAgent) > 0 { + spec.Command = thenShell(r.TabAgent) // not a bare shell, which what is sent to it would run + s.Diag.Event("restart.tabAgent", "session", r.ID, "agent", r.TabAgent[0]) + } if r.Display { // A new display: the old one went with the daemon. if disp, err := s.displays.start(r.ID); err == nil { @@ -302,6 +335,7 @@ func (s *Server) restore() { continue } s.persist.update(sess, r.Command) + s.restored(sess, r) } } diff --git a/daemon/restart.go b/daemon/restart.go new file mode 100644 index 00000000..4e80e54c --- /dev/null +++ b/daemon/restart.go @@ -0,0 +1,136 @@ +package daemon + +import ( + "path/filepath" + "slices" + "strings" + + "github.com/enowx/enowx/core/agent" + "github.com/enowx/enowx/core/diag" + "github.com/enowx/enowx/core/procstat" + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// What a daemon's restart stops, said before it, and brought back after. It ends what +// sessions run, as their terminals are its own: an update waits while that would cost +// anything (hostconn), and when it can't wait, what can come back does, exactly. + +// carryOnPrompt tells an agent whose turn a restart cut short to go on. +const carryOnPrompt = "[enowX] The daemon restarted while you were working, which cut your turn short. Carry on from where you stopped. Check first what already ran before repeating anything." + +// restartWhatMost bounds a command as listed. +const restartWhatMost = 200 + +// restartPlan says what a restart would stop now, and what the last one ended. +func (s *Server) restartPlan() proto.RestartPlan { + plan := proto.RestartPlan{Lost: s.restartLostNow()} + for _, sess := range s.sessions.List() { + info := sess.Info() + if info.Exited || info.Queued { + continue + } + if _, isAgent := agent.Find(info.Agent); isAgent { + if atWork(info) { + plan.Agents = append(plan.Agents, proto.RestartItem{Session: info.ID, Name: info.Name, What: info.Agent}) + } + continue + } + for _, args := range s.sessionCommands(info.ID) { + plan.Commands = append(plan.Commands, proto.RestartItem{Session: info.ID, Name: info.Name, What: commandWords(args)}) + } + } + s.Diag.Event("restart.plan", "agents", len(plan.Agents), "commands", len(plan.Commands), "lost", len(plan.Lost)) + return plan +} + +// atWork says an agent is mid-turn, or asking: not one whose state went stale, as an +// interrupted one's does. +func atWork(info proto.SessionInfo) bool { + return info.State == proto.StateWorking && !info.StateStale || info.State == proto.StateWaiting +} + +// sessionCommands are the programs a terminal's shell is running now. +func (s *Server) sessionCommands(id string) [][]string { + own := s.sessionProgram(id) + st := &s.statsState + var out [][]string + seen := map[int]bool{} + for pid := range own { + for _, child := range st.sampler.Children(pid) { + if own[child] || seen[child] { + continue + } + seen[child] = true + if args := procstat.Args(child); len(args) > 0 { + out = append(out, args) + } + } + } + slices.SortFunc(out, func(a, b []string) int { return strings.Compare(strings.Join(a, " "), strings.Join(b, " ")) }) + return out +} + +// commandWords is a command to list: its secrets masked, its program by name. +func commandWords(args []string) string { + words := append([]string{filepath.Base(args[0])}, args[1:]...) + return clip(diag.Scrub(strings.Join(words, " ")), restartWhatMost) +} + +// noteForRestart writes down, as the daemon stops, what can't be read after: the agents +// mid-turn, and what runs in terminals, which either comes back or is said to be lost. +func (s *Server) noteForRestart() { + for _, sess := range s.sessions.List() { + info, meta := sess.Info(), sess.Meta() + if info.Exited || info.Queued || !meta.Relaunch { + continue + } + note := restartNote{} + if _, isAgent := agent.Find(info.Agent); isAgent { + note.midTurn = atWork(info) + } else { + for _, args := range s.sessionCommands(info.ID) { + if back, ok := agent.TabAgentCommand(args); ok && note.tabAgent == nil { + note.tabAgent = back + } else { + note.lost = append(note.lost, commandWords(args)) + } + } + } + if note.midTurn || note.tabAgent != nil || len(note.lost) > 0 { + s.persist.noteRestart(info.ID, note) + s.Diag.Event("restart.noted", "session", info.ID, "midTurn", note.midTurn, "tabAgent", note.tabAgent != nil, "lost", len(note.lost)) + } + } +} + +// restored finishes what a restart noted of session sess: an agent mid-turn is told to +// carry on, once, as it is told only here and its record has forgotten by then. +func (s *Server) restored(sess *session.Session, r record) { + for _, what := range r.Lost { + s.restartLostAdd(proto.RestartItem{Session: r.ID, Name: r.Name, What: what}) + } + if !r.MidTurn { + return + } + s.background.start(func() { + if err := s.waitSessionReady(sess.ID()); err != nil { + s.Diag.Event("restart.carryOn", "session", sess.ID(), "err", err) + return + } + err := s.input(proto.SessionInputParams{ID: sess.ID(), Text: carryOnPrompt, Paste: true, Enter: true}) + s.Diag.Event("restart.carryOn", "session", sess.ID(), "err", err) + }) +} + +func (s *Server) restartLostAdd(item proto.RestartItem) { + s.restartLostMu.Lock() + defer s.restartLostMu.Unlock() + s.restartLost = append(s.restartLost, item) +} + +func (s *Server) restartLostNow() []proto.RestartItem { + s.restartLostMu.Lock() + defer s.restartLostMu.Unlock() + return slices.Clone(s.restartLost) +} diff --git a/daemon/restart_test.go b/daemon/restart_test.go new file mode 100644 index 00000000..5cedee7d --- /dev/null +++ b/daemon/restart_test.go @@ -0,0 +1,250 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + "time" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/core/session" + "github.com/enowx/enowx/proto" +) + +// restartAgent is a stand-in claude: it says how it was started and what it hears. +func restartAgent(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + bin := t.TempDir() + script := "#!/bin/sh\necho \"started: $*\"\necho '❯ '\nwhile read -r line; do echo \"heard: $line\"; done\n" + if err := os.WriteFile(filepath.Join(bin, "claude"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("SHELL", "/bin/sh") + return filepath.Join(bin, "claude") +} + +func restartServers(t *testing.T) (string, *Server, *client.Client) { + t.Helper() + dir := t.TempDir() + srv, err := NewServer(dir) + if err != nil { + t.Fatal(err) + } + return dir, srv, connect(t, srv, nil) +} + +func screenOf(t *testing.T, srv *Server, id string) string { + t.Helper() + sess, err := srv.sessions.Get(id) + if err != nil { + t.Fatal(err) + } + return strings.Join(strings.Fields(sess.Terminal().Text()), "") +} + +func waitInScreen(t *testing.T, srv *Server, id, what string) { + t.Helper() + what = strings.Join(strings.Fields(what), "") + waitFor(t, what, func() bool { return strings.Contains(screenOf(t, srv, id), what) }) +} + +func agentSession(t *testing.T, c *client.Client) proto.SessionInfo { + t.Helper() + info, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", Dir: t.TempDir(), Cols: 120, Rows: 40}) + if err != nil { + t.Fatal(err) + } + return info +} + +// The plan says what a restart would stop: agents at work, what runs in terminals; +// not an idle agent, an idle shell or one whose state went stale. +func TestRestartPlanSaysWhatWouldStop(t *testing.T) { + restartAgent(t) + _, srv, c := restartServers(t) + t.Cleanup(srv.Close) + idle, busy := agentSession(t, c), agentSession(t, c) + waitInScreen(t, srv, busy.ID, "started:") + sess, _ := srv.sessions.Get(busy.ID) + if err := srv.sessions.SetState(sess, proto.StateWorking); err != nil { + t.Fatal(err) + } + quiet, err := c.CreateSession(proto.SessionCreateParams{Agent: "shell", Dir: t.TempDir(), Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + running, err := c.CreateSession(proto.SessionCreateParams{Agent: "shell", Dir: t.TempDir(), Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + time.Sleep(500 * time.Millisecond) // the shells at their prompts + if err := c.Input(running.ID, "sleep 1234", false, true); err != nil { + t.Fatal(err) + } + var plan proto.RestartPlan + waitFor(t, "the command seen", func() bool { + var err error + plan, err = c.RestartPlan() + return err == nil && len(plan.Commands) > 0 + }) + if len(plan.Agents) != 1 || plan.Agents[0].Session != busy.ID || plan.Agents[0].What != "claude" { + t.Fatalf("agents: %+v (idle is %s)", plan.Agents, idle.ID) + } + if len(plan.Commands) != 1 || plan.Commands[0].Session != running.ID || !strings.Contains(plan.Commands[0].What, "sleep 1234") { + t.Fatalf("commands: %+v (quiet is %s)", plan.Commands, quiet.ID) + } + if plan.Empty() { + t.Fatal("empty") + } +} + +// An agent cut off mid-turn is told once to carry on, on its conversation, its prompt +// not sent again. One that was idle is told nothing. +func TestAgentMidTurnCarriesOnAfterARestart(t *testing.T) { + restartAgent(t) + dir, srv, c := restartServers(t) + first, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", Dir: t.TempDir(), Prompt: "the first prompt", Cols: 120, Rows: 40}) + if err != nil { + t.Fatal(err) + } + idle := agentSession(t, c) + waitInScreen(t, srv, first.ID, "started:") + sess, _ := srv.sessions.Get(first.ID) + if err := srv.sessions.SetState(sess, proto.StateWorking); err != nil { + t.Fatal(err) + } + srv.Close() + + again, err := NewServer(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(again.Close) + waitInScreen(t, again, first.ID, "heard: [enowX] The daemon restarted") + time.Sleep(300 * time.Millisecond) + text := screenOf(t, again, first.ID) + if n := strings.Count(text, "heard:[enowX]"); n != 1 { + t.Fatalf("told %d times: %s", n, text) + } + if strings.Count(text, "heard:thefirstprompt") != 0 { + t.Fatalf("its prompt went again: %s", text) + } + if strings.Contains(screenOf(t, again, idle.ID), "heard:[enowX]") { + t.Fatal("an idle agent was told to carry on") + } + // A further restart has nothing more to say. + again2 := filepath.Join(dir, "sessions.json") + data, err := os.ReadFile(again2) + if err != nil || strings.Contains(string(data), "midTurn") { + t.Fatalf("the note stayed: %v", err) + } +} + +// An agent run by hand in a terminal comes back as itself, on its conversation and in +// its mode, not as a shell that would run what it is sent; its prompt never again. A +// command is said to have ended, and isn't run again. +func TestTerminalsAfterARestart(t *testing.T) { + agentPath := restartAgent(t) // by its path: a login shell sets a PATH of its own + dir, srv, c := restartServers(t) + newShell := func() proto.SessionInfo { + info, err := c.CreateSession(proto.SessionCreateParams{Agent: "shell", Dir: t.TempDir(), Cols: 160, Rows: 40}) + if err != nil { + t.Fatal(err) + } + return info + } + tab, cmd := newShell(), newShell() + time.Sleep(500 * time.Millisecond) + if err := c.Input(tab.ID, agentPath+` --model opus --dangerously-skip-permissions "do the thing"`, false, true); err != nil { + t.Fatal(err) + } + if err := c.Input(cmd.ID, "sleep 4321", false, true); err != nil { + t.Fatal(err) + } + waitInScreen(t, srv, tab.ID, "started: --model opus") + waitFor(t, "both running", func() bool { + plan, err := c.RestartPlan() + return err == nil && len(plan.Commands) == 2 + }) + srv.Close() + + again, err := NewServer(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(again.Close) + waitInScreen(t, again, tab.ID, "started: --continue --model opus --dangerously-skip-permissions") + if err := connect(t, again, nil).Input(tab.ID, "are you there", false, true); err != nil { + t.Fatal(err) + } + waitInScreen(t, again, tab.ID, "heard: are you there") + if strings.Contains(strings.SplitN(screenOf(t, again, tab.ID), "enowXrestarted", 2)[1], "dothething") { + t.Fatalf("its prompt went again: %s", screenOf(t, again, tab.ID)) + } + waitInScreen(t, again, cmd.ID, "This was running here, and ended when the daemon restarted: sleep 4321") + plan, err := connect(t, again, nil).RestartPlan() + if err != nil || !slices.ContainsFunc(plan.Lost, func(it proto.RestartItem) bool { + return it.Session == cmd.ID && strings.Contains(it.What, "sleep 4321") + }) { + t.Fatalf("not listed: %+v, %v", plan.Lost, err) + } + if slices.ContainsFunc(plan.Commands, func(it proto.RestartItem) bool { return strings.Contains(it.What, "sleep 4321") }) { + t.Fatal("run again") + } +} + +// A secret in a command stays out of what is listed. +func TestCommandWordsMaskSecrets(t *testing.T) { + got := commandWords([]string{"/usr/bin/curl", "-H", "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345", "https://x"}) + if strings.Contains(got, "abcdefghijklmnop") || !strings.HasPrefix(got, "curl") { + t.Fatal(got) + } +} + +// GOD of Agents' tasks stay as they were, and what waits for it stays, through a restart. +func TestGOAStateSurvivesARestart(t *testing.T) { + restartAgent(t) + dir, srv, c := restartServers(t) + g, err := c.CreateSession(proto.SessionCreateParams{Agent: "claude", GOA: true, Cols: 100, Rows: 30}) + if err != nil { + t.Fatal(err) + } + worker := agentSession(t, c) + task, err := srv.goa.board.Add(proto.GOATask{Title: "Totals", Session: worker.ID, Status: proto.GOATaskWorking}) + if err != nil { + t.Fatal(err) + } + srv.goa.mu.Lock() + srv.goa.tell = append(srv.goa.tell, goaLine{Text: "[enowX GOA] waiting for it"}) + srv.goa.mu.Unlock() + srv.saveGOATell() + srv.Close() + + again, err := NewServer(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(again.Close) + tasks := again.goa.board.List() + i := slices.IndexFunc(tasks, func(x proto.GOATask) bool { return x.ID == task.ID }) + if i < 0 || tasks[i].Status != proto.GOATaskWorking || tasks[i].Session != worker.ID { + t.Fatalf("the board: %+v", tasks) + } + if _, err := again.sessions.Get(g.ID); err != nil { + t.Fatal(err) + } + var sess *session.Session + if sess, err = again.sessions.Get(worker.ID); err != nil || sess.Info().Exited { + t.Fatalf("the worker: %v", err) + } + waitFor(t, "the line waits or has gone in", func() bool { + return slices.Contains(again.goaWaitingLines(), "[enowX GOA] waiting for it") || strings.Contains(screenOf(t, again, g.ID), "heard:[enowXGOA]waitingforit") + }) +} diff --git a/daemon/rpc_hostsettings.go b/daemon/rpc_hostsettings.go index e4eb208f..c2af1f43 100644 --- a/daemon/rpc_hostsettings.go +++ b/daemon/rpc_hostsettings.go @@ -31,6 +31,7 @@ var hostSettingHandlers = map[string]handler{ proto.MethodDaemonLog: with(func(c *conn, p proto.DaemonLogParams) (any, error) { return c.srv.logTail(max(p.Lines, 1)), nil }), + proto.MethodDaemonRestartPlan: bare(func(c *conn) (any, error) { return c.srv.restartPlan(), nil }), proto.MethodDaemonDiagnostics: bare(func(c *conn) (any, error) { return c.srv.diagnostics(), nil }), proto.MethodDaemonRestart: bare(func(c *conn) (any, error) { // After the answer is out: the app reconnects, which starts the diff --git a/daemon/server.go b/daemon/server.go index 1aaf7945..8c1da11d 100644 --- a/daemon/server.go +++ b/daemon/server.go @@ -60,6 +60,9 @@ type Server struct { // Diag receives the connections' events, for later (core/diag); nil discards them. Diag *diag.Log + restartLostMu sync.Mutex + restartLost []proto.RestartItem // what the last restart ended (restart.go) + sessions *session.Manager projects *project.Store notes *notes.Store @@ -339,6 +342,7 @@ func (s *Server) Close() { s.stopShares() s.shares.mu.Unlock() s.projWatches.stopAll() + s.noteForRestart() s.persist.stopping() var tags []tag for _, sess := range s.sessions.List() { diff --git a/docs/daemon.md b/docs/daemon.md index 1fc32140..74fdd827 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -24,6 +24,22 @@ Where there is no systemd (Alpine, containers) or lingering couldn't be turned o The app's main thread is watched: asked to answer every second, it leaves a report in `watchdog/` under the data folder when it takes over 0.7 seconds (at most once a minute: lag) or 3 seconds (a stall). A report holds every goroutine's stack, whether a frame was being built, and what was on screen as of its last answer (the page, session, agent and style, never what's in them). The newest 30 stay. `watchdog/app.log` and `watchdog/daemon.log` get a line every 5 minutes with the Go heap, its objects, goroutines and slow frames, so growth over hours shows, and a mark when memory jumps. A panic's stack goes to `app.log` or `enowxd.log`; a process killed from outside leaves no report, and its next start says `lastRun=unclean`. All the logs, their limits and how to get them: [diagnostics.md](diagnostics.md). +## When a daemon restarts + +A session's program runs on a terminal the daemon holds, so a daemon that stops ends it. So an update waits while that would cost anything ([updates.md](updates.md)). When it does stop (asked for, Settings' **Restart the daemon**, a server's reboot), what can come back does: + +| What ran | After a restart | +|---|---| +| An agent, idle | Back on its conversation, in its mode, model and permissions | +| An agent mid-turn | The same, and told once to carry on from where it stopped, checking what already ran. Its prompt isn't sent again. Only when the daemon stopped in an orderly way: a crash notes nothing | +| An agent run by hand in a terminal | Back on its conversation (its `--continue`, or its `--resume ID`) and then the shell, with its mode, model and permission flags alone, so never a prompt or a key. A one-shot run (`-p`, `exec`) isn't brought back | +| A command in a terminal | Ended, and never run again. The terminal says what it was, the app says so as it connects, and the shell comes back | +| GOD of Agents' board, lines waiting for it and tasks queued | Kept on disk, and carried on | + +Each step goes in the daemon's log (`restart.*`, [diagnostics.md](diagnostics.md)). + +What each system gets today is the same on macOS, Linux and Windows, and on remote hosts. Keeping programs alive across a daemon's replacement isn't built yet: a small process holding the terminals would do it on macOS and Linux, and Windows' ConPTY needs its own answer. + ## What Clean cache never touches `core/cacheclean` lists only `screenshots/` (not those a note names), `reviews/`, `unslop/`, `trash/`, `intercept/profiles/`, `canvas-chat/`, `notes-chat/`, `app-backup/`, `worktrees/`, `watchdog/` reports, older rotated logs and saved outputs by their exact names ([diagnostics.md](diagnostics.md)), and thins canvases' versions. Nothing else can be removed: the vault and its keys (`vault/`, `vault-run/`, `configs-run/`), `cloud/`, `accounts`, `settings/`, `sessions.json`, `agent-sessions/`, notes, canvases, `tokens/`, `goa-board.json`, `usersync/`, `link-bases/`, `enowx.db`, Traffic's certificate, sessions' terminal history, and anything a running session or process uses. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 21b9e375..2d243f44 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -43,6 +43,8 @@ Lines read `TIME EVENT key=value…`, with the same connection's `conn=` on both | `canvas.load` | app | which canvas, its designs, its HTML's size, how long | | `webview.created`, `webview.destroyed`, `webview.render` | app | the Canvas page's web view; a hidden one for a render (at most once in 10 s) | | `frame.slow` | app | a frame over 250 ms, with what was on screen, at most once in 30 s | +| `daemon.kept`, `daemon.replaceAsked`, `daemon.replaceWhenDone` | app | a daemon of another version left running, with how much it runs; replacing it, asked for or once nothing would stop | +| `restart.plan`, `restart.noted`, `restart.tabAgent`, `restart.carryOn` | daemon | what a restart would stop; what was noted of a session as the daemon stopped; an agent brought back in a terminal; an agent told to carry on | | `logs.swept` | both | files and bytes removed | | `log.dropped` | both | lines lost because the writer fell behind | diff --git a/docs/updates.md b/docs/updates.md index 01770a55..610422fb 100644 --- a/docs/updates.md +++ b/docs/updates.md @@ -7,7 +7,8 @@ The app updates itself (`ui/updates.go`), with MyGo's updater: it downloads the - A little after it starts (15 s) and every 6 hours, the app asks for a newer version, unless **Check for updates automatically** is off in the update dialog. - A version found shows in a card at the window's bottom right: **Update now** installs it there, **What's new** opens the dialog, **Later** puts the card off until the app starts again (each launch shows it again, until the version is installed or skipped). It also stays as **Update to x.y.z** in the title bar. - The chip opens the dialog: the release notes, then **Install update**, **Later** or **Skip this version** (a skipped version shows again only when asked for). Installing shows its progress and can be cancelled; the running app goes on meanwhile. -- **Restart now** runs the new version. Its daemon is of the same version, so the app replaces the local one as it connects (`hostconn`), and the daemon brings the sessions back, agents continuing their conversations. Servers' daemons are upgraded the same way at their next connection. +- **Restart now** runs the new version. Sessions keep running: their programs belong to the daemon, which outlives the app. +- **The daemon updates only when that stops nothing.** As the app connects it asks the daemon what a restart would stop now (`daemon.restartPlan`): agents at work or asking, and what runs in terminals. With nothing, the app replaces it, as before, and idle agents come back on their conversations. Otherwise the daemon stays at its version and a card says what would stop: **Restart it now** (it asks first) or **When agents are done**, which replaces it once the plan is empty. Ignored, the card changes nothing, and the next connection looks again. Servers work the same way, by the daemon they already run. A daemon too old to say (0.6.2 or before) is judged by its sessions, every terminal counting, as it can't say what runs there. See [daemon.md](daemon.md#when-a-daemon-restarts). - **Check for updates** in the command palette asks now and says how it went. Development builds (`make dev`) and builds without an update feed don't update themselves: the palette says so, and the background checks stay quiet. diff --git a/hostconn/hostconn.go b/hostconn/hostconn.go index 256c5e35..46048b32 100644 --- a/hostconn/hostconn.go +++ b/hostconn/hostconn.go @@ -54,6 +54,12 @@ type Options struct { TrustHost func(addr, fingerprint string) bool // DaemonCommand starts this machine's daemon when none runs. DaemonCommand []string + // ReplaceBusy replaces a daemon of another version even if that stops work: the + // user said so. Otherwise it stays while it would, and Kept is told. + ReplaceBusy bool + // Kept, if set, is told a daemon of another version was left running, and what + // replacing it would stop. + Kept func(info proto.DaemonInfo, plan proto.RestartPlan) // Password gives the SSH password of hosts with PasswordAuth. Password func(hosts.Host) (string, error) // Passphrase is asked for the passphrase of an SSH key the server @@ -141,9 +147,12 @@ func connectLocal(opts Options) (*client.Client, error) { // (a test, a tool) uses the daemon there, never stops it. return c, nil } - // A daemon left running by another version of the app speaks another - // protocol, and one from another build of this version runs other code: - // replace it. Its sessions end with it. + // A daemon left running by another version of the app, or another build of this + // one, runs other code: replace it, unless that would stop work. Its sessions' + // programs end with it, and connections stay compatible across versions. + if keepBusyDaemon(c, info, opts) { + return c, nil + } if info.Version == version.String { opts.progress("Restarting the daemon (new build of the app)") } else { @@ -156,6 +165,22 @@ func connectLocal(opts Options) (*client.Client, error) { return dial() } +// keepBusyDaemon says the daemon c reaches stays, replacing it being something to ask +// about first. A daemon that can't say is left alone too: nothing dies unasked. +func keepBusyDaemon(c *client.Client, info proto.DaemonInfo, opts Options) bool { + if opts.ReplaceBusy { + return false + } + plan, err := c.RestartPlan() + if err == nil && plan.Empty() { + return false + } + if opts.Kept != nil { + opts.Kept(info, plan) + } + return true +} + func connectRemote(h hosts.Host, opts Options) (*client.Client, error) { target, err := transport.ParseSSHTarget(h.Address) if err != nil { @@ -197,6 +222,14 @@ func connectRemote(h hosts.Host, opts Options) (*client.Client, error) { ssh.Close() return nil, err } + if busy, err := remoteDaemonBusy(ssh, user, target, opts); err == nil && busy { + c, err := bridge(ssh, user, target, opts, ssh.Bridge) + if err != nil { + return nil, err + } + c.SetTunnel(ssh.DialTCP) + return c, nil + } arch, err := ensureDaemon(ssh, user, opts) if err != nil { ssh.Close() @@ -228,6 +261,43 @@ func connectRemote(h hosts.Host, opts Options) (*client.Client, error) { return c, nil } +// remoteDaemonBusy says a daemon of another version runs on the server and stays, +// replacing it being something to ask about first. Only one already running is asked: +// starting one for this would bring its sessions back just to replace them. +func remoteDaemonBusy(ssh *transport.SSHClient, user string, target transport.SSHTarget, opts Options) (bool, error) { + if opts.ReplaceBusy { + return false, nil + } + bin := `"$HOME/` + transport.RemoteDaemonPath + `"` + if _, err := ssh.Run(transport.AsUser(user, `pgrep -u "$(id -u)" -f 'enowxd serve' >/dev/null`), nil); err != nil { + return false, nil // none runs, or there's no asking + } + out, err := ssh.Run(transport.AsUser(user, inspectScript(bin)), nil) + if err != nil { + return false, err + } + uname, have, hash := parseInspect(out) + arch, err := linuxArch(uname) + if err != nil { + return false, err + } + if want := embeddedHash(arch); have == "" || have == version.String && (hash == "" || want == "" || hash == want) { + return false, nil // it is this build, or there is none + } + probeOpts := opts + probeOpts.OnEvent = nil // the app hears the daemon it keeps + probe, err := bridge(ssh, user, target, probeOpts, ssh.BridgeSession) + if err != nil { + return false, err + } + defer probe.Close() + info, err := probe.Info() + if err != nil { + return false, err + } + return keepBusyDaemon(probe, info, opts), nil +} + // bridge opens a client to the server's daemon, starting it when needed. func bridge(ssh *transport.SSHClient, user string, target transport.SSHTarget, opts Options, open func(string) (io.ReadWriteCloser, error)) (*client.Client, error) { stream, err := open(user) diff --git a/hostconn/kept_test.go b/hostconn/kept_test.go new file mode 100644 index 00000000..5aa1cdaa --- /dev/null +++ b/hostconn/kept_test.go @@ -0,0 +1,116 @@ +package hostconn + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/proto" +) + +// Real daemons, of another build than this test, in a home of their own. One running +// a command stays, its session alive, until the user says otherwise; one with nothing +// to lose is replaced, as before; replacing it says what it ended. +func TestUpdateKeepsABusyDaemon(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses sh") + } + home, err := os.MkdirTemp("", "enowx-kept") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(home) }) + bin := filepath.Join(home, "enowxd") + if out, err := exec.Command("go", "build", "-o", bin, "../cmd/enowxd").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + t.Setenv("ENOWX_HOME", filepath.Join(home, "data")) + t.Setenv("SHELL", "/bin/sh") + t.Cleanup(func() { _ = exec.Command(bin, "stop").Run() }) + // In the background of a shell that ends, as a daemon outlives the app that started + // it: a child of this test would stay a zombie when stopped, and never seem gone. + opts := Options{DaemonCommand: []string{"/bin/sh", "-c", bin + " serve >/dev/null 2>&1 &"}} + connect := func(o Options) *client.Client { + t.Helper() + c, err := connectLocal(o) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { c.Close() }) + return c + } + first := connect(opts) + a, err := first.Info() + if err != nil { + t.Fatal(err) + } + // Nothing runs: replaced. + second := connect(opts) + b, err := second.Info() + if err != nil { + t.Fatal(err) + } + if a.PID == b.PID { + t.Fatalf("a daemon with nothing to lose wasn't replaced: %d", a.PID) + } + + info, err := second.CreateSession(proto.SessionCreateParams{Agent: "shell", Dir: t.TempDir(), Cols: 80, Rows: 24}) + if err != nil { + t.Fatal(err) + } + time.Sleep(time.Second) + if err := second.Input(info.ID, "sleep 3333", false, true); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(20 * time.Second) + for { + plan, err := second.RestartPlan() + if err == nil && len(plan.Commands) == 1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("the command wasn't seen: %+v, %v", plan, err) + } + time.Sleep(200 * time.Millisecond) + } + + // Busy: it stays, with its command, and says so. + var kept proto.RestartPlan + withKept := opts + withKept.Kept = func(_ proto.DaemonInfo, plan proto.RestartPlan) { kept = plan } + third := connect(withKept) + c, err := third.Info() + if err != nil { + t.Fatal(err) + } + if c.PID != b.PID { + t.Fatalf("a busy daemon was replaced: %d, then %d", b.PID, c.PID) + } + if len(kept.Commands) != 1 || !strings.Contains(kept.Commands[0].What, "sleep 3333") { + t.Fatalf("kept: %+v", kept) + } + + // The user says so: replaced, and what ended is listed, not run again. + asked := opts + asked.ReplaceBusy = true + fourth := connect(asked) + d, err := fourth.Info() + if err != nil { + t.Fatal(err) + } + if d.PID == c.PID { + t.Fatal("not replaced when asked") + } + plan, err := fourth.RestartPlan() + if err != nil { + t.Fatal(err) + } + if len(plan.Lost) != 1 || !strings.Contains(plan.Lost[0].What, "sleep 3333") || len(plan.Commands) != 0 { + t.Fatalf("after: %+v", plan) + } +} diff --git a/proto/restart.go b/proto/restart.go new file mode 100644 index 00000000..3624012a --- /dev/null +++ b/proto/restart.go @@ -0,0 +1,28 @@ +package proto + +// MethodDaemonRestartPlan says what replacing a host's daemon would cost now, for an +// update to wait or ask: → RestartPlan. +const MethodDaemonRestartPlan = "daemon.restartPlan" + +// RestartItem is one thing a daemon's restart stops or stopped. +type RestartItem struct { + Session string `json:"session"` + Name string `json:"name,omitempty"` + // What is an agent's name, or a command as run, secrets masked. + What string `json:"what"` +} + +// RestartPlan is what a restart of a host's daemon would stop now. Agents come back on +// their conversations; what runs in terminals ends, unless it is an agent, which comes +// back too. +type RestartPlan struct { + Agents []RestartItem `json:"agents,omitempty"` // at work or asking: their turn is cut short + Commands []RestartItem `json:"commands,omitempty"` // running in terminals + // Lost is what the last restart ended, and couldn't bring back. + Lost []RestartItem `json:"lost,omitempty"` + // Unknown: a daemon too old to look into terminals, whose every terminal is listed. + Unknown bool `json:"unknown,omitempty"` +} + +// Empty says a restart would stop nothing of the user's now. +func (p RestartPlan) Empty() bool { return len(p.Agents) == 0 && len(p.Commands) == 0 } diff --git a/proto/testdata/api.txt b/proto/testdata/api.txt index 1393d616..5c7f0116 100644 --- a/proto/testdata/api.txt +++ b/proto/testdata/api.txt @@ -1853,6 +1853,13 @@ field ResizeParams.stream uint32 field Response.error string field Response.id uint64 field Response.result json.RawMessage +field RestartItem.name string +field RestartItem.session string +field RestartItem.what string +field RestartPlan.agents []RestartItem +field RestartPlan.commands []RestartItem +field RestartPlan.lost []RestartItem +field RestartPlan.unknown bool field ReviewRef.base string field ReviewRef.session string field ReviewStatus.ahead int @@ -2824,6 +2831,7 @@ method daemon.diagnostics method daemon.info method daemon.log method daemon.restart +method daemon.restartPlan method daemon.setSettings method daemon.settings method daemon.stats diff --git a/ui/app.go b/ui/app.go index b1ef803e..aa0a8f73 100644 --- a/ui/app.go +++ b/ui/app.go @@ -72,6 +72,8 @@ type App struct { watchdog *watchdog.Watch // the main thread's, once the window is open diag *diag.Log // what happened, for later (diagnostics.go); nil in tests diagPage string // the page last logged + daemonBehind *daemonBehind // a daemon left at another version, to replace (daemon_behind.go) + replaceDaemon string // the host whose daemon the next connection replaces, asked for sessionToasts []*sessionToast // what notifications are about (sessiontoast.go) sessionToastKey int trust *trustPrompt diff --git a/ui/connection.go b/ui/connection.go index 89d4651d..0d19c43e 100644 --- a/ui/connection.go +++ b/ui/connection.go @@ -77,6 +77,7 @@ func (a *App) connect(h hosts.Host) { } opts := a.hostOptions(gen) opts.OnEvent = func(ev proto.Event) { a.handleEvent(gen, ev) } + a.daemonBehindOptions(&opts, h, gen) opts.Progress = func(s string) { a.onMain(func() { if gen == a.gen && a.connecting != "" { @@ -140,6 +141,7 @@ func (a *App) connect(h hosts.Host) { go func() { _ = c.ServeBrowsers() }() // an older daemon has no enowx browser go func() { _ = c.ServeApp() }() a.vaultConnected(c) + go a.followDaemonBehind(c, h, gen) }) }() } diff --git a/ui/daemon_behind.go b/ui/daemon_behind.go new file mode 100644 index 00000000..b278258d --- /dev/null +++ b/ui/daemon_behind.go @@ -0,0 +1,150 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/core/hosts" + "github.com/enowx/enowx/hostconn" + "github.com/enowx/enowx/proto" + "github.com/enowx/enowx/ui/icon" + "github.com/enowx/enowx/ui/theme" + "github.com/enowx/enowx/ui/widget" +) + +// An update replaces the app at once, and a host's daemon only when that stops nothing: +// its sessions' programs are its own, and end with it. Until then the daemon stays at +// its version and a card says what replacing it would stop, for the user to choose. + +// daemonBehindLook is how often a daemon left behind is asked what it runs now. +const daemonBehindLook = 30 * time.Second + +// daemonBehind is a host's daemon left at another version. +type daemonBehind struct { + host, name, version string + plan proto.RestartPlan + // waiting: replace it as soon as that stops nothing. hidden: don't show the card. + waiting, hidden bool +} + +// daemonBehindOptions has a connection to h say so if it leaves a daemon behind, and +// replace it if the user asked. +func (a *App) daemonBehindOptions(opts *hostconn.Options, h hosts.Host, gen int) { + opts.ReplaceBusy = a.replaceDaemon == h.ID + a.replaceDaemon = "" + if opts.ReplaceBusy { + a.diag.Event("daemon.replaceAsked", "host", h.ID) + } + waiting := a.daemonBehind != nil && a.daemonBehind.host == h.ID && a.daemonBehind.waiting + a.daemonBehind = nil + opts.Kept = func(info proto.DaemonInfo, plan proto.RestartPlan) { + a.diag.Event("daemon.kept", "host", h.ID, "version", info.Version, "agents", len(plan.Agents), "commands", len(plan.Commands)) + a.onMain(func() { + if gen == a.gen { + a.daemonBehind = &daemonBehind{host: h.ID, name: h.Name, version: info.Version, plan: plan, waiting: waiting} + } + }) + } +} + +// followDaemonBehind says what the last restart ended, and keeps a daemon left behind's +// plan current, replacing it once that stops nothing if the user chose that. +func (a *App) followDaemonBehind(c *client.Client, h hosts.Host, gen int) { + if plan, err := c.RestartPlan(); err == nil && len(plan.Lost) > 0 { + a.onMain(func() { + if gen == a.gen { + a.notify("The daemon's restart ended: " + restartItems(plan.Lost)) + } + }) + } + for { + select { + case <-c.Done(): + return + case <-time.After(daemonBehindLook): + } + plan, err := c.RestartPlan() + if err != nil { + continue + } + a.onMain(func() { + b := a.daemonBehind + if gen != a.gen || b == nil || b.host != h.ID { + return + } + b.plan = plan + if b.waiting && plan.Empty() { + a.diag.Event("daemon.replaceWhenDone", "host", h.ID) + a.replaceDaemon = h.ID + a.connect(h) + } + }) + } +} + +// showsDaemonBehindCard says the card has something to show. +func (a *App) showsDaemonBehindCard() bool { + return a.daemonBehind != nil && !a.daemonBehind.hidden && a.daemonBehind.host == a.host.ID +} + +// daemonBehindCard offers replacing a daemon left behind: now, saying what stops, or +// once its agents are done. +func (a *App) daemonBehindCard(c *ui.Context) { + if !a.showsDaemonBehindCard() { + return + } + b := a.daemonBehind + widget.Float(ui.Column(c).FillWidth().Padding(theme.S3).Gap(theme.S2).Radius(theme.RadiusCard). + Background(theme.Dialog).Border(1, theme.BorderStrong)).Transition(widget.OverlayMotion).Children(func() { + widget.Running(c, "enowxd on "+b.name+" stays at "+b.version+" for now", widget.RunStyle{Weight: 500}) + ui.Text(c, restartCost(b.plan)).FontSize(theme.TextLabel).TextColor(theme.TextSoft) + ui.Row(c).FillWidth().Gap(theme.S2).Justify(ui.End).Children(func() { + if widget.GhostButton(c, "When agents are done").Clicked() { + b.waiting, b.hidden = true, true + } + if widget.PrimaryButton(c, icon.Restore, "Restart it now", "").Clicked() { + a.confirm("Restart enowxd on "+b.name+"?", restartCost(b.plan), "Restart it", func() { + a.replaceDaemon = b.host + a.connect(a.host) + }) + } + }) + }) +} + +// restartCost says what replacing a daemon stops now. +func restartCost(plan proto.RestartPlan) string { + if plan.Empty() { + return "Replacing it stops nothing now." + } + var parts []string + if len(plan.Agents) > 0 { + parts = append(parts, fmt.Sprintf("Agents at work stop, then carry on from their conversation: %s.", restartItems(plan.Agents))) + } + if len(plan.Commands) > 0 { + parts = append(parts, fmt.Sprintf("These end, unless they are agents, which come back: %s.", restartItems(plan.Commands))) + } + return strings.Join(parts, " ") +} + +// restartItems lists items, the first few by what and session. +func restartItems(items []proto.RestartItem) string { + const most = 5 + var words []string + for i, it := range items { + if i == most { + words = append(words, fmt.Sprintf("and %d more", len(items)-most)) + break + } + word := it.What + if it.Name != "" { + word += " (" + it.Name + ")" + } + words = append(words, word) + } + return strings.Join(words, ", ") +} diff --git a/ui/daemon_behind_test.go b/ui/daemon_behind_test.go new file mode 100644 index 00000000..37886aff --- /dev/null +++ b/ui/daemon_behind_test.go @@ -0,0 +1,31 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/enowx/enowx/proto" +) + +// The card says exactly what would stop, and that nothing would when nothing would. +func TestRestartCostSaysWhat(t *testing.T) { + if got := restartCost(proto.RestartPlan{}); !strings.Contains(got, "stops nothing") { + t.Fatal(got) + } + got := restartCost(proto.RestartPlan{ + Agents: []proto.RestartItem{{Session: "a", Name: "enowx", What: "claude"}}, + Commands: []proto.RestartItem{{Session: "b", What: "npm run dev"}}, + }) + for _, want := range []string{"claude (enowx)", "npm run dev"} { + if !strings.Contains(got, want) { + t.Errorf("no %q in %q", want, got) + } + } + var many []proto.RestartItem + for range 8 { + many = append(many, proto.RestartItem{What: "sleep"}) + } + if got := restartItems(many); !strings.Contains(got, "and 3 more") { + t.Fatal(got) + } +} diff --git a/ui/reconnect_test.go b/ui/reconnect_test.go index c9112b4e..0ac4f295 100644 --- a/ui/reconnect_test.go +++ b/ui/reconnect_test.go @@ -18,6 +18,10 @@ import ( // once it answers, its sessions back in view. func TestReconnectsByItself(t *testing.T) { t.Setenv("ENOWX_HOME", t.TempDir()) + // A host with Xpra asks the screen's scale, which waits for a window's loop this has none of. + scale := screenScale + screenScale = func() int { return 1 } + t.Cleanup(func() { screenScale = scale }) path, err := transport.SocketPath() if err != nil { t.Fatal(err) diff --git a/ui/updates_card.go b/ui/updates_card.go index 5bbb6222..609d75f1 100644 --- a/ui/updates_card.go +++ b/ui/updates_card.go @@ -14,12 +14,13 @@ import ( // cornerCards are the cards over the window's bottom right corner: a new // version, teams deleted, then ports a session opened. func (a *App) cornerCards(c *ui.Context) { - if !a.showsUpdateCard() && !a.spacesGoneShown() && !a.showsVideoCard() && (len(a.portOffers) == 0 || a.client == nil) { + if !a.showsUpdateCard() && !a.showsDaemonBehindCard() && !a.spacesGoneShown() && !a.showsVideoCard() && (len(a.portOffers) == 0 || a.client == nil) { return } ui.Column(c.Key("corner-cards")).Absolute().Right(theme.S4).Bottom(40).Width(340).Gap(theme.S2).TextColor(theme.Text).Children(func() { a.videoCard(c.Key("video")) a.updateCard(c.Key("update")) + a.daemonBehindCard(c.Key("daemon-behind")) a.spacesGoneCards(c) a.portOfferCards(c) }) @@ -54,7 +55,7 @@ func (a *App) updateCard(c *ui.Context) { title = fmt.Sprintf("Updating to %s · %d%%", v.Version, u.done*100/u.total) } case updateReady: - title, detail = "enowX "+v.Version+" is installed", "Restart to run it: your sessions come back, agents on their conversations." + title, detail = "enowX "+v.Version+" is installed", "Restart to run it: your sessions keep running. Their daemon updates when that stops nothing, or when you say so." } widget.Running(c, title, widget.RunStyle{Weight: 500}) ui.Text(c, detail).FontSize(theme.TextLabel).TextColor(theme.TextSoft)