detached ce4cfbb

chore(broker): preserve crash logs and tighten review coverage

commit
ce4cfbbca2c3c096eec26ddad5813d687cf1c978
author
brice · · 2026-09-23 08:35 UTC
parent
472aae1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +172 -13
M internal/app/broker_offline_mux_test.go +12 -0
@@ -22,6 +22,7 @@ import (
2222 "github.com/bnema/vev/internal/adapters/brokerconfig"
2323 "github.com/bnema/vev/internal/adapters/brokeripc"
2424 "github.com/bnema/vev/internal/adapters/quic"
25+ "github.com/bnema/vev/internal/adapters/sshstdio"
2526 "github.com/bnema/vev/internal/ports"
2627 "github.com/bnema/vev/internal/protocol"
2728 )
@@ -380,6 +381,17 @@ func TestBrokerQUICPeerAddr(t *testing.T) {
380381 }
381382 }
382383
384+func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) {
385+ isolateSandboxEnv(t)
386+ root := writeMuxRoot(t, map[string]any{"kind": "unix", "path": filepath.Join(t.TempDir(), "missing-mux.sock")})
387+ parsed, err := parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, []string{"--offline-root", root})
388+ require.NoError(t, err)
389+ err = runBrokerMuxStdioCommand(context.Background(), parsed.brokerMux)
390+ var coded *exitCoded
391+ require.ErrorAs(t, err, &coded)
392+ require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code)
393+}
394+
383395 // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector
384396 // refuses any address the immutable configuration did not produce.
385397 func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) {
@@ -22,6 +22,7 @@ import (
22 "github.com/bnema/vev/internal/adapters/brokerconfig"22 "github.com/bnema/vev/internal/adapters/brokerconfig"
23 "github.com/bnema/vev/internal/adapters/brokeripc"23 "github.com/bnema/vev/internal/adapters/brokeripc"
24 "github.com/bnema/vev/internal/adapters/quic"24 "github.com/bnema/vev/internal/adapters/quic"
25+ "github.com/bnema/vev/internal/adapters/sshstdio"
25 "github.com/bnema/vev/internal/ports"26 "github.com/bnema/vev/internal/ports"
26 "github.com/bnema/vev/internal/protocol"27 "github.com/bnema/vev/internal/protocol"
27 )28 )
@@ -380,6 +381,17 @@ func TestBrokerQUICPeerAddr(t *testing.T) {
380 }381 }
381 }382 }
382 383
384+func TestBrokerMuxHelperReturnsTypedNoDaemonExit(t *testing.T) {
385+ isolateSandboxEnv(t)
386+ root := writeMuxRoot(t, map[string]any{"kind": "unix", "path": filepath.Join(t.TempDir(), "missing-mux.sock")})
387+ parsed, err := parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, []string{"--offline-root", root})
388+ require.NoError(t, err)
389+ err = runBrokerMuxStdioCommand(context.Background(), parsed.brokerMux)
390+ var coded *exitCoded
391+ require.ErrorAs(t, err, &coded)
392+ require.Equal(t, sshstdio.MuxExitNoDaemon, coded.code)
393+}
394+
383 // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector395 // TestBrokerMuxConnectorRejectsUnknownAddress proves the transport selector
384 // refuses any address the immutable configuration did not produce.396 // refuses any address the immutable configuration did not produce.
385 func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) {397 func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) {
M internal/app/broker_offline_status.go +20 -4
@@ -12,6 +12,7 @@ import (
1212 "os"
1313 "os/exec"
1414 "os/signal"
15+ "path/filepath"
1516 "strings"
1617 "syscall"
1718 "time"
@@ -270,7 +271,7 @@ func parseBrokerStatusArgs(args []string) (command, error) {
270271 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary
271272 // command and never runs in production flows.
272273 func runProductionBrokerLauncherCommand(context.Context) error {
273- return startDetachedBrokerServe([]string{productionBrokerServeCommand})
274+ return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log"))
274275 }
275276
276277 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {
@@ -300,7 +301,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error {
300301 if grace > 0 {
301302 args = append(args, "--idle-grace", grace.String())
302303 }
303- return startDetachedBrokerServe(args)
304+ return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log"))
304305 }
305306
306307 // startDetachedBrokerServe starts one `_broker-serve` in a new session and
@@ -308,7 +309,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error {
308309 // daemon launcher's double-fork boundary: no ambient variable is dropped beyond
309310 // the performance-trace inputs, stdio is /dev/null, and the process is
310311 // reparented immediately.
311-func startDetachedBrokerServe(args []string) error {
312+func startDetachedBrokerServe(args []string, crashPath string) error {
312313 exePath, err := selfExePath()
313314 if err != nil {
314315 return fmt.Errorf("vev: resolving executable path: %w", err)
@@ -324,7 +325,22 @@ func startDetachedBrokerServe(args []string) error {
324325 cmd.Dir = platform.DirOrHome("")
325326 cmd.Stdin = devNull
326327 cmd.Stdout = devNull
327- cmd.Stderr = devNull
328+ if crashPath == "" {
329+ cmd.Stderr = devNull
330+ } else {
331+ if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil {
332+ return fmt.Errorf("vev: secure broker crash log directory: %w", err)
333+ }
334+ if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) {
335+ return fmt.Errorf("vev: preserve previous broker crash log: %w", err)
336+ }
337+ crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
338+ if err != nil {
339+ return fmt.Errorf("vev: open broker crash log: %w", err)
340+ }
341+ defer func() { _ = crashLog.Close() }()
342+ cmd.Stderr = crashLog
343+ }
328344 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
329345 if err := cmd.Start(); err != nil {
330346 return fmt.Errorf("vev: starting detached broker: %w", err)
@@ -12,6 +12,7 @@ import (
12 "os"12 "os"
13 "os/exec"13 "os/exec"
14 "os/signal"14 "os/signal"
15+ "path/filepath"
15 "strings"16 "strings"
16 "syscall"17 "syscall"
17 "time"18 "time"
@@ -270,7 +271,7 @@ func parseBrokerStatusArgs(args []string) (command, error) {
270 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary271 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary
271 // command and never runs in production flows.272 // command and never runs in production flows.
272 func runProductionBrokerLauncherCommand(context.Context) error {273 func runProductionBrokerLauncherCommand(context.Context) error {
273- return startDetachedBrokerServe([]string{productionBrokerServeCommand})274+ return startDetachedBrokerServe([]string{productionBrokerServeCommand}, filepath.Join(productionBrokerLayout().Log, "vev-broker-crash.log"))
274 }275 }
275 276
276 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {277 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {
@@ -300,7 +301,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error {
300 if grace > 0 {301 if grace > 0 {
301 args = append(args, "--idle-grace", grace.String())302 args = append(args, "--idle-grace", grace.String())
302 }303 }
303- return startDetachedBrokerServe(args)304+ return startDetachedBrokerServe(args, filepath.Join(layout.Log, "vev-broker-crash.log"))
304 }305 }
305 306
306 // startDetachedBrokerServe starts one `_broker-serve` in a new session and307 // startDetachedBrokerServe starts one `_broker-serve` in a new session and
@@ -308,7 +309,7 @@ func runBrokerLauncher(options brokerLauncherOptions) error {
308 // daemon launcher's double-fork boundary: no ambient variable is dropped beyond309 // daemon launcher's double-fork boundary: no ambient variable is dropped beyond
309 // the performance-trace inputs, stdio is /dev/null, and the process is310 // the performance-trace inputs, stdio is /dev/null, and the process is
310 // reparented immediately.311 // reparented immediately.
311-func startDetachedBrokerServe(args []string) error {312+func startDetachedBrokerServe(args []string, crashPath string) error {
312 exePath, err := selfExePath()313 exePath, err := selfExePath()
313 if err != nil {314 if err != nil {
314 return fmt.Errorf("vev: resolving executable path: %w", err)315 return fmt.Errorf("vev: resolving executable path: %w", err)
@@ -324,7 +325,22 @@ func startDetachedBrokerServe(args []string) error {
324 cmd.Dir = platform.DirOrHome("")325 cmd.Dir = platform.DirOrHome("")
325 cmd.Stdin = devNull326 cmd.Stdin = devNull
326 cmd.Stdout = devNull327 cmd.Stdout = devNull
327- cmd.Stderr = devNull328+ if crashPath == "" {
329+ cmd.Stderr = devNull
330+ } else {
331+ if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil {
332+ return fmt.Errorf("vev: secure broker crash log directory: %w", err)
333+ }
334+ if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) {
335+ return fmt.Errorf("vev: preserve previous broker crash log: %w", err)
336+ }
337+ crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
338+ if err != nil {
339+ return fmt.Errorf("vev: open broker crash log: %w", err)
340+ }
341+ defer func() { _ = crashLog.Close() }()
342+ cmd.Stderr = crashLog
343+ }
328 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}344 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
329 if err := cmd.Start(); err != nil {345 if err := cmd.Start(); err != nil {
330 return fmt.Errorf("vev: starting detached broker: %w", err)346 return fmt.Errorf("vev: starting detached broker: %w", err)
M internal/app/broker_offline_test.go +2 -0
@@ -101,6 +101,8 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) {
101101 t.Helper()
102102 prodRuntime = t.TempDir()
103103 prodState = t.TempDir()
104+ t.Setenv("VEV_ENV", "")
105+ t.Setenv("VEV_ENV_ROOT", "")
104106 t.Setenv("XDG_RUNTIME_DIR", prodRuntime)
105107 t.Setenv("XDG_STATE_HOME", prodState)
106108 root = filepath.Join(shortTempDir(t, "vevs"), "sandbox")
@@ -101,6 +101,8 @@ func isolateSandboxEnv(t *testing.T) (root, prodRuntime, prodState string) {
101 t.Helper()101 t.Helper()
102 prodRuntime = t.TempDir()102 prodRuntime = t.TempDir()
103 prodState = t.TempDir()103 prodState = t.TempDir()
104+ t.Setenv("VEV_ENV", "")
105+ t.Setenv("VEV_ENV_ROOT", "")
104 t.Setenv("XDG_RUNTIME_DIR", prodRuntime)106 t.Setenv("XDG_RUNTIME_DIR", prodRuntime)
105 t.Setenv("XDG_STATE_HOME", prodState)107 t.Setenv("XDG_STATE_HOME", prodState)
106 root = filepath.Join(shortTempDir(t, "vevs"), "sandbox")108 root = filepath.Join(shortTempDir(t, "vevs"), "sandbox")
M internal/app/remote_hosts.go +21 -2
@@ -102,9 +102,16 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
102102 return err
103103 }
104104 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
105- _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
105+ _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS")
106106 for _, endpoint := range endpoints {
107- _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint)
107+ status := "configured"
108+ for _, daemon := range snapshot.Daemons {
109+ if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
110+ status = "no daemon"
111+ break
112+ }
113+ }
114+ _, _ = fmt.Fprintf(tw, "%s\tbroker\t%s\n", endpoint, status)
108115 }
109116 return tw.Flush()
110117 }
@@ -114,12 +121,16 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
114121 return fmt.Errorf("vev: reading broker catalogue: %w", err)
115122 }
116123 var sessions []protocol.SessionInfo
124+ var statuses []string
117125 found := cmd.listHost == ""
118126 for _, daemon := range snapshot.Daemons {
119127 if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) {
120128 continue
121129 }
122130 found = true
131+ if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
132+ statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint))
133+ }
123134 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {
124135 if daemon.Local {
125136 session.Name = daemon.Sessions[i].Name
@@ -130,6 +141,14 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
130141 if !found {
131142 return fmt.Errorf("vev: unknown host %q", cmd.listHost)
132143 }
144+ for _, status := range statuses {
145+ if _, err := fmt.Fprintln(stdout, status); err != nil {
146+ return err
147+ }
148+ }
149+ if len(sessions) == 0 && len(statuses) > 0 {
150+ return nil
151+ }
133152 printSessions(stdout, sessions)
134153 return nil
135154 }
@@ -102,9 +102,16 @@ func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
102 return err102 return err
103 }103 }
104 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)104 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
105- _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")105+ _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE\tSTATUS")
106 for _, endpoint := range endpoints {106 for _, endpoint := range endpoints {
107- _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint)107+ status := "configured"
108+ for _, daemon := range snapshot.Daemons {
109+ if !daemon.Local && daemon.Endpoint == endpoint && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
110+ status = "no daemon"
111+ break
112+ }
113+ }
114+ _, _ = fmt.Fprintf(tw, "%s\tbroker\t%s\n", endpoint, status)
108 }115 }
109 return tw.Flush()116 return tw.Flush()
110 }117 }
@@ -114,12 +121,16 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
114 return fmt.Errorf("vev: reading broker catalogue: %w", err)121 return fmt.Errorf("vev: reading broker catalogue: %w", err)
115 }122 }
116 var sessions []protocol.SessionInfo123 var sessions []protocol.SessionInfo
124+ var statuses []string
117 found := cmd.listHost == ""125 found := cmd.listHost == ""
118 for _, daemon := range snapshot.Daemons {126 for _, daemon := range snapshot.Daemons {
119 if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) {127 if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) {
120 continue128 continue
121 }129 }
122 found = true130 found = true
131+ if !daemon.Local && daemon.Availability == domain.RemoteAvailabilityNoDaemon {
132+ statuses = append(statuses, fmt.Sprintf("%s: no vev daemon — Enter in the picker to create a session", daemon.Endpoint))
133+ }
123 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {134 for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {
124 if daemon.Local {135 if daemon.Local {
125 session.Name = daemon.Sessions[i].Name136 session.Name = daemon.Sessions[i].Name
@@ -130,6 +141,14 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
130 if !found {141 if !found {
131 return fmt.Errorf("vev: unknown host %q", cmd.listHost)142 return fmt.Errorf("vev: unknown host %q", cmd.listHost)
132 }143 }
144+ for _, status := range statuses {
145+ if _, err := fmt.Fprintln(stdout, status); err != nil {
146+ return err
147+ }
148+ }
149+ if len(sessions) == 0 && len(statuses) > 0 {
150+ return nil
151+ }
133 printSessions(stdout, sessions)152 printSessions(stdout, sessions)
134 return nil153 return nil
135 }154 }
M internal/app/run_broker_test.go +3 -3
@@ -218,9 +218,6 @@ func seamKillResult(requestID uint64, outcome protocol.KillOutcome, code uint16,
218218 return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text}
219219 }
220220
221-// TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the
222-// live session list through BrokerOperations over the per-user broker and
223-// renders it, so a local list never reads durable state or dials a daemon.
224221 func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) {
225222 remote := ports.BrokerDaemonObservation{
226223 Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test",
@@ -258,6 +255,9 @@ func TestRunBrokerSnapshotListAllShowsNoDaemonAlongsideSessions(t *testing.T) {
258255 require.Contains(t, output.String(), "work")
259256 }
260257
258+// TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the
259+// live session list through BrokerOperations over the per-user broker and
260+// renders it, so a local list never reads durable state or dials a daemon.
261261 func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) {
262262 stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{
263263 {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},
@@ -218,9 +218,6 @@ func seamKillResult(requestID uint64, outcome protocol.KillOutcome, code uint16,
218 return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text}218 return protocol.KillResult{RequestID: requestID, Outcome: outcome, Code: code, Text: text}
219 }219 }
220 220
221-// TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the
222-// live session list through BrokerOperations over the per-user broker and
223-// renders it, so a local list never reads durable state or dials a daemon.
224 func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) {221 func TestRunBrokerSnapshotListReportsNoDaemon(t *testing.T) {
225 remote := ports.BrokerDaemonObservation{222 remote := ports.BrokerDaemonObservation{
226 Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test",223 Endpoint: "demo@host.test", DisplayOrigin: "demo@host.test",
@@ -258,6 +255,9 @@ func TestRunBrokerSnapshotListAllShowsNoDaemonAlongsideSessions(t *testing.T) {
258 require.Contains(t, output.String(), "work")255 require.Contains(t, output.String(), "work")
259 }256 }
260 257
258+// TestRunListRendersBrokerSessionsWithoutADaemon pins that runList reads the
259+// live session list through BrokerOperations over the per-user broker and
260+// renders it, so a local list never reads durable state or dials a daemon.
261 func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) {261 func TestRunListRendersBrokerSessionsWithoutADaemon(t *testing.T) {
262 stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{262 stream := newSeamBrokerStream(protocol.Sessions{Sessions: []protocol.SessionInfo{
263 {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},263 {Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},
M internal/usecase/client/picker_catalogue.go +11 -1
@@ -596,7 +596,13 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
596596 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
597597 return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"})
598598 }
599- if refuseFailing && !ref.local && pickerObservationFailing(observation) {
599+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact {
600+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"})
601+ }
602+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral {
603+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"})
604+ }
605+ if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon {
600606 reason := pickerObservationReason(observation, false)
601607 return fail(pickerCatalogueError{
602608 Code: pickerCatalogueUnavailable,
@@ -843,6 +849,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
843849 return protocol.PickerLineStatusError
844850 case domain.RemoteAvailabilityIncompatible:
845851 return protocol.PickerLineStatusVersion
852+ case domain.RemoteAvailabilityNoDaemon:
853+ return protocol.PickerLineStatusNoDaemon
846854 case domain.RemoteAvailabilityReachable:
847855 if pickerObservationVersionMismatch(observation) {
848856 return protocol.PickerLineStatusVersion
@@ -873,6 +881,8 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo
873881 return domain.RemoteReasonMalformed
874882 case domain.RemoteAvailabilityIncompatible:
875883 return domain.RemoteReasonVersionMismatch
884+ case domain.RemoteAvailabilityNoDaemon:
885+ return domain.RemoteReasonNoDaemon
876886 case domain.RemoteAvailabilityReachable:
877887 if pickerObservationVersionMismatch(observation) {
878888 return domain.RemoteReasonVersionMismatch
@@ -596,7 +596,13 @@ func resolvePickerTarget(epoch ports.BrokerEpoch, authority pickerResolveAuthori
596 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {596 if observation.Availability == domain.RemoteAvailabilityIncompatible || pickerObservationVersionMismatch(observation) {
597 return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"})597 return fail(pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"})
598 }598 }
599- if refuseFailing && !ref.local && pickerObservationFailing(observation) {599+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind == pickerSelectionExact {
600+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "remote host has no vev daemon; choose create session"})
601+ }
602+ if observation.Availability == domain.RemoteAvailabilityNoDaemon && ref.kind != pickerSelectionCreateNamed && ref.kind != pickerSelectionCreateEphemeral {
603+ return fail(pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "no daemon exists on this host; choose create session"})
604+ }
605+ if refuseFailing && !ref.local && pickerObservationFailing(observation) && observation.Availability != domain.RemoteAvailabilityNoDaemon {
600 reason := pickerObservationReason(observation, false)606 reason := pickerObservationReason(observation, false)
601 return fail(pickerCatalogueError{607 return fail(pickerCatalogueError{
602 Code: pickerCatalogueUnavailable,608 Code: pickerCatalogueUnavailable,
@@ -843,6 +849,8 @@ func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bo
843 return protocol.PickerLineStatusError849 return protocol.PickerLineStatusError
844 case domain.RemoteAvailabilityIncompatible:850 case domain.RemoteAvailabilityIncompatible:
845 return protocol.PickerLineStatusVersion851 return protocol.PickerLineStatusVersion
852+ case domain.RemoteAvailabilityNoDaemon:
853+ return protocol.PickerLineStatusNoDaemon
846 case domain.RemoteAvailabilityReachable:854 case domain.RemoteAvailabilityReachable:
847 if pickerObservationVersionMismatch(observation) {855 if pickerObservationVersionMismatch(observation) {
848 return protocol.PickerLineStatusVersion856 return protocol.PickerLineStatusVersion
@@ -873,6 +881,8 @@ func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bo
873 return domain.RemoteReasonMalformed881 return domain.RemoteReasonMalformed
874 case domain.RemoteAvailabilityIncompatible:882 case domain.RemoteAvailabilityIncompatible:
875 return domain.RemoteReasonVersionMismatch883 return domain.RemoteReasonVersionMismatch
884+ case domain.RemoteAvailabilityNoDaemon:
885+ return domain.RemoteReasonNoDaemon
876 case domain.RemoteAvailabilityReachable:886 case domain.RemoteAvailabilityReachable:
877 if pickerObservationVersionMismatch(observation) {887 if pickerObservationVersionMismatch(observation) {
878 return domain.RemoteReasonVersionMismatch888 return domain.RemoteReasonVersionMismatch
M internal/usecase/client/picker_catalogue_test.go +83 -3
@@ -189,6 +189,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
189189 wantStatus map[string]protocol.PickerLineStatus
190190 wantDim map[string]bool
191191 wantHostRow bool
192+ wantNoDaemon bool
192193 }{
193194 {
194195 name: "local only",
@@ -212,6 +213,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
212213 wantSessions: []string{"remote-a"},
213214 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
214215 },
216+ {
217+ name: "reachable host with no daemon offers a create row", wantNoDaemon: true,
218+ daemons: []ports.BrokerDaemonObservation{
219+ func() ports.BrokerDaemonObservation {
220+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now)
221+ observation.Identity = ""
222+ observation.Incarnation = ports.BrokerDaemonIncarnation{}
223+ observation.ProtocolVersion = 0
224+ observation.Availability = domain.RemoteAvailabilityNoDaemon
225+ observation.LastSuccess = time.Time{}
226+ observation.InventoryKnown = false
227+ return observation
228+ }(),
229+ },
230+ wantSections: []string{"user@arch"},
231+ wantSessions: []string{},
232+ wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNoDaemon},
233+ wantHostRow: true,
234+ },
215235 {
216236 name: "stale observation stays explicitly selectable",
217237 daemons: []ports.BrokerDaemonObservation{
@@ -289,8 +309,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
289309 lines := catalogue.Lines()
290310 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
291311 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
292- _, hasHost := pickerHostLine(lines)
312+ hostLine, hasHost := pickerHostLine(lines)
293313 require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information")
314+ if tt.wantNoDaemon {
315+ require.Equal(t, protocol.PickerLineStatusNoDaemon, hostLine.Status)
316+ require.Equal(t, protocol.PickerCanNavigate, hostLine.Actions, "Enter creates an ephemeral session on this host")
317+ require.True(t, hostLine.Focusable)
318+ require.Contains(t, hostLine.StatusDetail, "no_daemon")
319+ }
294320 for label, actions := range tt.wantActions {
295321 line, ok := pickerLineByLabel(lines, label)
296322 require.True(t, ok, "session %q must be projected", label)
@@ -787,6 +813,7 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T)
787813 {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
788814 {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
789815 {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure},
816+ {name: "reachable without daemon is distinct and createable", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon }), wantStatus: protocol.PickerLineStatusNoDaemon, wantReason: domain.RemoteReasonNoDaemon},
790817 {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed},
791818 {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) {
792819 o.Availability = domain.RemoteAvailabilityUnreachable
@@ -819,6 +846,9 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
819846 name string
820847 daemon ports.BrokerDaemonObservation
821848 wantErr pickerCatalogueErrorCode
849+ hostRow bool
850+ create bool
851+ exact bool
822852 }{
823853 {
824854 name: "unobserved unknown stays attemptable",
@@ -858,6 +888,33 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
858888 return o
859889 }(),
860890 },
891+ {
892+ name: "no daemon allows create admission", hostRow: true, create: true,
893+ daemon: func() ports.BrokerDaemonObservation {
894+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
895+ o.Identity = ""
896+ o.Incarnation = ports.BrokerDaemonIncarnation{}
897+ o.ProtocolVersion = 0
898+ o.Availability = domain.RemoteAvailabilityNoDaemon
899+ o.LastSuccess = time.Time{}
900+ o.InventoryKnown = false
901+ return o
902+ }(),
903+ },
904+ {
905+ name: "no daemon refuses exact absent session attach", hostRow: true, exact: true,
906+ daemon: func() ports.BrokerDaemonObservation {
907+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
908+ o.Identity = ""
909+ o.Incarnation = ports.BrokerDaemonIncarnation{}
910+ o.ProtocolVersion = 0
911+ o.Availability = domain.RemoteAvailabilityNoDaemon
912+ o.LastSuccess = time.Time{}
913+ o.InventoryKnown = false
914+ return o
915+ }(),
916+ wantErr: pickerCatalogueUnavailable,
917+ },
861918 {
862919 name: "reachable mismatch refuses incompatible",
863920 daemon: func() ports.BrokerDaemonObservation {
@@ -881,14 +938,37 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
881938 t.Run(tt.name, func(t *testing.T) {
882939 catalogue, _ := pickerTestCatalogue(t)
883940 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
884- line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
941+ lines := catalogue.Lines()
942+ line, ok := pickerLineByLabel(lines, "alpha")
943+ if tt.hostRow {
944+ line, ok = pickerHostLine(lines)
945+ }
946+ if tt.create {
947+ require.True(t, ok, "the no-daemon host row is visible")
948+ ref, exists := catalogue.Ref(line.Key)
949+ require.True(t, exists)
950+ request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
951+ require.NoError(t, err)
952+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
953+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
954+ return
955+ }
956+ if tt.exact {
957+ ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
958+ registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"}
959+ _, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
960+ require.Error(t, err)
961+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
962+ return
963+ }
885964 require.True(t, ok)
886- request, err := catalogue.Resolve(line.Key, pickerTestBase())
887965 if tt.wantErr != 0 {
966+ _, err := catalogue.Resolve(line.Key, pickerTestBase())
888967 require.Error(t, err)
889968 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
890969 return
891970 }
971+ request, err := catalogue.Resolve(line.Key, pickerTestBase())
892972 require.NoError(t, err)
893973 require.NoError(t, request.Validate())
894974 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization")
@@ -189,6 +189,7 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
189 wantStatus map[string]protocol.PickerLineStatus189 wantStatus map[string]protocol.PickerLineStatus
190 wantDim map[string]bool190 wantDim map[string]bool
191 wantHostRow bool191 wantHostRow bool
192+ wantNoDaemon bool
192 }{193 }{
193 {194 {
194 name: "local only",195 name: "local only",
@@ -212,6 +213,25 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
212 wantSessions: []string{"remote-a"},213 wantSessions: []string{"remote-a"},
213 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},214 wantActions: map[string]protocol.PickerLineActions{"remote-a": protocol.PickerCanNavigate},
214 },215 },
216+ {
217+ name: "reachable host with no daemon offers a create row", wantNoDaemon: true,
218+ daemons: []ports.BrokerDaemonObservation{
219+ func() ports.BrokerDaemonObservation {
220+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now)
221+ observation.Identity = ""
222+ observation.Incarnation = ports.BrokerDaemonIncarnation{}
223+ observation.ProtocolVersion = 0
224+ observation.Availability = domain.RemoteAvailabilityNoDaemon
225+ observation.LastSuccess = time.Time{}
226+ observation.InventoryKnown = false
227+ return observation
228+ }(),
229+ },
230+ wantSections: []string{"user@arch"},
231+ wantSessions: []string{},
232+ wantStatus: map[string]protocol.PickerLineStatus{"user@arch": protocol.PickerLineStatusNoDaemon},
233+ wantHostRow: true,
234+ },
215 {235 {
216 name: "stale observation stays explicitly selectable",236 name: "stale observation stays explicitly selectable",
217 daemons: []ports.BrokerDaemonObservation{237 daemons: []ports.BrokerDaemonObservation{
@@ -289,8 +309,14 @@ func TestPickerCatalogueProjectionCases(t *testing.T) {
289 lines := catalogue.Lines()309 lines := catalogue.Lines()
290 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))310 require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
291 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))311 require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
292- _, hasHost := pickerHostLine(lines)312+ hostLine, hasHost := pickerHostLine(lines)
293 require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information")313 require.Equal(t, tt.wantHostRow, hasHost, "a host status row is published only when it adds information")
314+ if tt.wantNoDaemon {
315+ require.Equal(t, protocol.PickerLineStatusNoDaemon, hostLine.Status)
316+ require.Equal(t, protocol.PickerCanNavigate, hostLine.Actions, "Enter creates an ephemeral session on this host")
317+ require.True(t, hostLine.Focusable)
318+ require.Contains(t, hostLine.StatusDetail, "no_daemon")
319+ }
294 for label, actions := range tt.wantActions {320 for label, actions := range tt.wantActions {
295 line, ok := pickerLineByLabel(lines, label)321 line, ok := pickerLineByLabel(lines, label)
296 require.True(t, ok, "session %q must be projected", label)322 require.True(t, ok, "session %q must be projected", label)
@@ -787,6 +813,7 @@ func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T)
787 {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},813 {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
788 {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},814 {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
789 {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure},815 {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure},
816+ {name: "reachable without daemon is distinct and createable", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityNoDaemon }), wantStatus: protocol.PickerLineStatusNoDaemon, wantReason: domain.RemoteReasonNoDaemon},
790 {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed},817 {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed},
791 {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) {818 {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) {
792 o.Availability = domain.RemoteAvailabilityUnreachable819 o.Availability = domain.RemoteAvailabilityUnreachable
@@ -819,6 +846,9 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
819 name string846 name string
820 daemon ports.BrokerDaemonObservation847 daemon ports.BrokerDaemonObservation
821 wantErr pickerCatalogueErrorCode848 wantErr pickerCatalogueErrorCode
849+ hostRow bool
850+ create bool
851+ exact bool
822 }{852 }{
823 {853 {
824 name: "unobserved unknown stays attemptable",854 name: "unobserved unknown stays attemptable",
@@ -858,6 +888,33 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
858 return o888 return o
859 }(),889 }(),
860 },890 },
891+ {
892+ name: "no daemon allows create admission", hostRow: true, create: true,
893+ daemon: func() ports.BrokerDaemonObservation {
894+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
895+ o.Identity = ""
896+ o.Incarnation = ports.BrokerDaemonIncarnation{}
897+ o.ProtocolVersion = 0
898+ o.Availability = domain.RemoteAvailabilityNoDaemon
899+ o.LastSuccess = time.Time{}
900+ o.InventoryKnown = false
901+ return o
902+ }(),
903+ },
904+ {
905+ name: "no daemon refuses exact absent session attach", hostRow: true, exact: true,
906+ daemon: func() ports.BrokerDaemonObservation {
907+ o := pickerTestRemoteObservation("user@arch", 1, 1, now)
908+ o.Identity = ""
909+ o.Incarnation = ports.BrokerDaemonIncarnation{}
910+ o.ProtocolVersion = 0
911+ o.Availability = domain.RemoteAvailabilityNoDaemon
912+ o.LastSuccess = time.Time{}
913+ o.InventoryKnown = false
914+ return o
915+ }(),
916+ wantErr: pickerCatalogueUnavailable,
917+ },
861 {918 {
862 name: "reachable mismatch refuses incompatible",919 name: "reachable mismatch refuses incompatible",
863 daemon: func() ports.BrokerDaemonObservation {920 daemon: func() ports.BrokerDaemonObservation {
@@ -881,14 +938,37 @@ func TestPickerCatalogueResolutionAdmission(t *testing.T) {
881 t.Run(tt.name, func(t *testing.T) {938 t.Run(tt.name, func(t *testing.T) {
882 catalogue, _ := pickerTestCatalogue(t)939 catalogue, _ := pickerTestCatalogue(t)
883 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))940 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
884- line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")941+ lines := catalogue.Lines()
942+ line, ok := pickerLineByLabel(lines, "alpha")
943+ if tt.hostRow {
944+ line, ok = pickerHostLine(lines)
945+ }
946+ if tt.create {
947+ require.True(t, ok, "the no-daemon host row is visible")
948+ ref, exists := catalogue.Ref(line.Key)
949+ require.True(t, exists)
950+ request, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
951+ require.NoError(t, err)
952+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, request.Admission)
953+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
954+ return
955+ }
956+ if tt.exact {
957+ ref := pickerSelectionRef{kind: pickerSelectionExact, epoch: catalogue.snapshot.Epoch, endpoint: tt.daemon.Endpoint,
958+ registration: tt.daemon.Registration, lifecycle: domain.SessionLifecycleID{1}, name: "absent"}
959+ _, err := resolvePickerRequest(catalogue.snapshot.Epoch, pickerAuthorityInSnapshot(catalogue.snapshot, ref), ref, pickerTestBase())
960+ require.Error(t, err)
961+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
962+ return
963+ }
885 require.True(t, ok)964 require.True(t, ok)
886- request, err := catalogue.Resolve(line.Key, pickerTestBase())
887 if tt.wantErr != 0 {965 if tt.wantErr != 0 {
966+ _, err := catalogue.Resolve(line.Key, pickerTestBase())
888 require.Error(t, err)967 require.Error(t, err)
889 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)968 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
890 return969 return
891 }970 }
971+ request, err := catalogue.Resolve(line.Key, pickerTestBase())
892 require.NoError(t, err)972 require.NoError(t, err)
893 require.NoError(t, request.Validate())973 require.NoError(t, request.Validate())
894 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization")974 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode, "an explicit attempt keeps the start authorization")
M internal/usecase/client/supervisor_attach.go +7 -0
@@ -596,8 +596,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
596596 }
597597 request.Env = append([]string(nil), sessionEnv.Env...)
598598
599+ s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation)
599600 stream, err := service.OpenStream(deadline.Context(), request)
600601 if err != nil {
602+ s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err))
601603 s.reportAttachmentFailure(deadline.cause(err))
602604 return false, nil
603605 }
@@ -627,6 +629,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
627629 }
628630
629631 token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)}
632+ s.logger.Debug("client_stream_opened", "local", request.Local, "endpoint", request.Endpoint, "generation", token.Generation, "attempt", token.Attempt)
630633 run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream)
631634 if !ok {
632635 // Another foreground owns the terminal, or the shared reader is
@@ -734,16 +737,19 @@ settlement:
734737 return false, nil
735738 }
736739 if errors.Is(result.event.Err, errDetachAndExit) {
740+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "explicit_detach")
737741 s.transition(supervisorEvent{kind: supervisorAttachEnded})
738742 s.notifyLifecycle(LifecycleNoticeDetachAndExit)
739743 return true, nil
740744 }
741745 if errors.Is(result.event.Err, errDetachToPicker) {
746+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "detach_to_picker")
742747 s.transition(supervisorEvent{kind: supervisorAttachEnded})
743748 s.notifyLifecycle(LifecycleNoticeDetachToPicker)
744749 return false, nil
745750 }
746751 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
752+ s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err))
747753 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
748754 s.notifyLifecycle(LifecycleNoticeDestinationFailed)
749755 s.notifyAttachment(result.event.Err)
@@ -755,6 +761,7 @@ settlement:
755761 // attached presentation showing.
756762 s.transition(supervisorEvent{kind: supervisorAttachEnded})
757763 if !brokerLost {
764+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String())
758765 s.notifyLifecycle(LifecycleNoticeSessionEnded)
759766 }
760767 return false, nil
@@ -596,8 +596,10 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
596 }596 }
597 request.Env = append([]string(nil), sessionEnv.Env...)597 request.Env = append([]string(nil), sessionEnv.Env...)
598 598
599+ s.logger.Debug("client_stream_open_begin", "local", request.Local, "endpoint", request.Endpoint, "admission", request.Admission, "generation", s.State().Generation)
599 stream, err := service.OpenStream(deadline.Context(), request)600 stream, err := service.OpenStream(deadline.Context(), request)
600 if err != nil {601 if err != nil {
602+ s.logger.Warn("client_stream_open_failed", "local", request.Local, "endpoint", request.Endpoint, "code", brokerErrorCode(err), "error", err, "cause", errors.Unwrap(err))
601 s.reportAttachmentFailure(deadline.cause(err))603 s.reportAttachmentFailure(deadline.cause(err))
602 return false, nil604 return false, nil
603 }605 }
@@ -627,6 +629,7 @@ func (s *Supervisor) attachResolved(ctx context.Context, input *terminalInputLif
627 }629 }
628 630
629 token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)}631 token := AttachmentToken{Generation: s.State().Generation, Attempt: s.nextAttachment.Add(1)}
632+ s.logger.Debug("client_stream_opened", "local", request.Local, "endpoint", request.Endpoint, "generation", token.Generation, "attempt", token.Attempt)
630 run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream)633 run, ok := s.attachments.Begin(deadline.Context(), token, worker, stream)
631 if !ok {634 if !ok {
632 // Another foreground owns the terminal, or the shared reader is635 // Another foreground owns the terminal, or the shared reader is
@@ -734,16 +737,19 @@ settlement:
734 return false, nil737 return false, nil
735 }738 }
736 if errors.Is(result.event.Err, errDetachAndExit) {739 if errors.Is(result.event.Err, errDetachAndExit) {
740+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "explicit_detach")
737 s.transition(supervisorEvent{kind: supervisorAttachEnded})741 s.transition(supervisorEvent{kind: supervisorAttachEnded})
738 s.notifyLifecycle(LifecycleNoticeDetachAndExit)742 s.notifyLifecycle(LifecycleNoticeDetachAndExit)
739 return true, nil743 return true, nil
740 }744 }
741 if errors.Is(result.event.Err, errDetachToPicker) {745 if errors.Is(result.event.Err, errDetachToPicker) {
746+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", "detach_to_picker")
742 s.transition(supervisorEvent{kind: supervisorAttachEnded})747 s.transition(supervisorEvent{kind: supervisorAttachEnded})
743 s.notifyLifecycle(LifecycleNoticeDetachToPicker)748 s.notifyLifecycle(LifecycleNoticeDetachToPicker)
744 return false, nil749 return false, nil
745 }750 }
746 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {751 if result.adopted && (result.event.Kind == AttachmentEventLost || result.event.Kind == AttachmentEventFailed) {
752+ s.logger.Warn("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String(), "error", result.event.Err, "cause", errors.Unwrap(result.event.Err))
747 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})753 s.transition(supervisorEvent{kind: supervisorAttachEnded, err: result.event.Err})
748 s.notifyLifecycle(LifecycleNoticeDestinationFailed)754 s.notifyLifecycle(LifecycleNoticeDestinationFailed)
749 s.notifyAttachment(result.event.Err)755 s.notifyAttachment(result.event.Err)
@@ -755,6 +761,7 @@ settlement:
755 // attached presentation showing.761 // attached presentation showing.
756 s.transition(supervisorEvent{kind: supervisorAttachEnded})762 s.transition(supervisorEvent{kind: supervisorAttachEnded})
757 if !brokerLost {763 if !brokerLost {
764+ s.logger.Info("client_stream_closed", "local", request.Local, "endpoint", request.Endpoint, "lifecycle", request.Target.LifecycleID.String(), "session", request.Target.SessionName, "reason", result.event.Kind.String())
758 s.notifyLifecycle(LifecycleNoticeSessionEnded)765 s.notifyLifecycle(LifecycleNoticeSessionEnded)
759 }766 }
760 return false, nil767 return false, nil
M internal/usecase/picker/model_test.go +13 -0
@@ -303,6 +303,19 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
303303 require.Contains(t, rowText(frame.Row(1)), "[stopped]")
304304 }
305305
306+func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
307+ m := New([]protocol.PickerLine{{
308+ Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions",
309+ Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session",
310+ Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
311+ }}, Config{Intent: protocol.PickerIntentNavigation})
312+ frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{})
313+ require.Contains(t, rowText(frame.Row(0)), "[no daemon]")
314+ require.Contains(t, rowText(frame.Row(7)), "no daemon")
315+ require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
316+ require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable")
317+}
318+
306319 func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) {
307320 m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation})
308321 preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1}
@@ -303,6 +303,19 @@ func TestRenderDrawsStatusBadgesAndStoppedRows(t *testing.T) {
303 require.Contains(t, rowText(frame.Row(1)), "[stopped]")303 require.Contains(t, rowText(frame.Row(1)), "[stopped]")
304 }304 }
305 305
306+func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
307+ m := New([]protocol.PickerLine{{
308+ Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions",
309+ Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session",
310+ Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
311+ }}, Config{Intent: protocol.PickerIntentNavigation})
312+ frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{})
313+ require.Contains(t, rowText(frame.Row(0)), "[no daemon]")
314+ require.Contains(t, rowText(frame.Row(7)), "no daemon")
315+ require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
316+ require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable")
317+}
318+
306 func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) {319 func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) {
307 m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation})320 m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation})
308 preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1}321 preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close