detached 0be98f1

feat(app): prompt before creating missing named sessions

commit
0be98f18aabaaff06e3fe66cc3922716cf65c1df
author
brice · · 2026-09-22 20:51 UTC
parent
4895225
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +695 -3
A internal/app/attach_missing_prompt.go +196 -0
@@ -0,0 +1,196 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/confirm"
13+ "github.com/bnema/vev/pkg/rawterm"
14+)
15+
16+// terminalIsInteractive reports whether the prompt can read its answer from a
17+// console. The client terminal's input has to be a real terminal file: pipes,
18+// redirected files, and detached readers are not consoles, so the prompt
19+// never blocks on a stream nobody reads from the keyboard.
20+func terminalIsInteractive(terminal ports.Terminal) bool {
21+ if terminal == nil {
22+ return false
23+ }
24+ file, ok := terminal.In().(*os.File)
25+ return ok && rawterm.IsTerminal(int(file.Fd()))
26+}
27+
28+// attachInteractiveConsole is the composition-root seam for the console
29+// decision. Production probes the real terminal file; tests inject the
30+// decision so the prompt matrix runs without owning a real TTY.
31+var attachInteractiveConsole = terminalIsInteractive
32+
33+// flushingWriter pushes every prompt write to the console before the
34+// confirmer blocks on the answer. Terminal output is buffered, so without
35+// this flush the user waits in front of a blank console until the client
36+// repaints.
37+type flushingWriter struct {
38+ out io.Writer
39+ flush func() error
40+}
41+
42+func (w flushingWriter) Write(data []byte) (int, error) {
43+ written, err := w.out.Write(data)
44+ if err != nil {
45+ return written, err
46+ }
47+ if err := w.flush(); err != nil {
48+ return written, err
49+ }
50+ return written, nil
51+}
52+
53+// resolveAttachCreationIntent decides the attach intent for one direct exact
54+// `attach <name>` target (local when remoteTarget is empty, otherwise a
55+// configured remote host) before the client owns any console reader or raw
56+// mode. It reuses the broker's own attach resolution (the same exact-target
57+// lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so
58+// existence is decided from one committed broker publication, never from
59+// error-text matching.
60+//
61+// When the session exists, the host is not configured, or existence cannot be
62+// determined (the broker is unreachable), it keeps IntentAttach unchanged and
63+// the resolver's own rejection, if any, surfaces unchanged later. When the
64+// session is confirmed absent on a known host, it offers creation on an
65+// interactive console and returns IntentNew on confirmation; a declined
66+// answer, an unusable console, or a non-interactive console all keep
67+// IntentAttach so the existing resolver reports its own clear refusal.
68+//
69+// The check runs before Supervisor.Run enters raw mode, so the create prompt
70+// is always the sole console reader: no attachment worker can leave a
71+// blocked input-pump read behind to steal the answer.
72+//
73+// When the peek connection succeeds, it is returned so the caller can hand it
74+// to a preconnectedBrokerConnector instead of dialing the broker a second
75+// time: an ordinary attach, confirmed or declined, still opens exactly one
76+// broker connection.
77+func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remoteTarget string, terminal ports.Terminal) (uint8, ports.BrokerService, error) {
78+ if intent != protocol.IntentAttach || name == "" {
79+ return intent, nil, nil
80+ }
81+ service, err := connectProductionClientBroker(ctx)
82+ if err != nil {
83+ // Existence could not be determined: keep the plain attach path and let
84+ // the resolver's own refusal (or the ordinary connector's own retry
85+ // cadence) decide, exactly as if this preflight had never run.
86+ return protocol.IntentAttach, nil, nil
87+ }
88+ exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name)
89+ if exists || !hostKnown {
90+ return protocol.IntentAttach, service, nil
91+ }
92+ resolved, err := confirmMissingSessionCreate(ctx, name, terminal)
93+ if err != nil {
94+ _ = service.Close()
95+ return protocol.IntentAttach, nil, err
96+ }
97+ return resolved, service, nil
98+}
99+
100+// attachTargetSessionExists reports whether one exact-name attach target is
101+// present in the committed publication, and whether the target's daemon (the
102+// local daemon, or the named remote host) is even a configured observation.
103+// hostKnown is false only when the daemon itself is absent from the snapshot;
104+// a known daemon with no matching session name reports exists=false,
105+// hostKnown=true.
106+func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) {
107+ var observation ports.BrokerDaemonObservation
108+ var ok bool
109+ if remoteTarget == "" {
110+ observation, ok = localBrokerObservation(snapshot)
111+ } else {
112+ observation, ok = snapshot.Find(remoteTarget)
113+ }
114+ if !ok {
115+ return false, false
116+ }
117+ _, found := brokerObservationExactTarget(observation, name)
118+ return found, true
119+}
120+
121+// confirmMissingSessionCreate prompts to create an absent session and maps
122+// the answer to the attach intent: IntentNew on confirmation, IntentAttach on
123+// decline. A cancelled wait surfaces the cancellation error. Non-interactive
124+// consoles, a declined answer, or unreadable input keep IntentAttach. Only
125+// y/yes answers confirm; empty, no, and unknown answers decline.
126+func confirmMissingSessionCreate(ctx context.Context, name string, terminal ports.Terminal) (uint8, error) {
127+ if err := ctx.Err(); err != nil {
128+ return protocol.IntentAttach, err
129+ }
130+ if name == "" {
131+ return protocol.IntentAttach, nil
132+ }
133+ if !attachInteractiveConsole(terminal) {
134+ return protocol.IntentAttach, nil
135+ }
136+ in, out := terminal.In(), terminal.Out()
137+ if in == nil || out == nil {
138+ return protocol.IntentAttach, nil
139+ }
140+ prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush})
141+ create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name))
142+ if err != nil {
143+ if ctxErr := ctx.Err(); ctxErr != nil {
144+ return protocol.IntentAttach, ctxErr
145+ }
146+ // A console that cannot show the question must not consume an answer:
147+ // keep the plain attach path and let the resolver decide.
148+ return protocol.IntentAttach, nil
149+ }
150+ if !create {
151+ return protocol.IntentAttach, nil
152+ }
153+ if err := ctx.Err(); err != nil {
154+ return protocol.IntentAttach, err
155+ }
156+ return protocol.IntentNew, nil
157+}
158+
159+// preconnectedBrokerConnector hands one already-connected broker service to
160+// the caller's first Connect call, then falls back to the wrapped connector
161+// for every later attempt (the supervisor's own reconnect cadence). It exists
162+// only to avoid wasting the connection the attach-creation preflight already
163+// established: the supervisor still owns and closes whatever it adopts.
164+type preconnectedBrokerConnector struct {
165+ first ports.BrokerService
166+ fallback ports.BrokerConnector
167+
168+ mu sync.Mutex
169+ used bool
170+}
171+
172+func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
173+ c.mu.Lock()
174+ if !c.used {
175+ c.used = true
176+ first := c.first
177+ c.mu.Unlock()
178+ if first != nil {
179+ return first, nil
180+ }
181+ } else {
182+ c.mu.Unlock()
183+ }
184+ return c.fallback.Connect(ctx)
185+}
186+
187+var _ ports.BrokerConnector = (*preconnectedBrokerConnector)(nil)
188+
189+// withPreconnected wraps connector to serve preconnected as its first
190+// connection, or returns connector unchanged when preconnected is nil.
191+func withPreconnected(connector ports.BrokerConnector, preconnected ports.BrokerService) ports.BrokerConnector {
192+ if preconnected == nil {
193+ return connector
194+ }
195+ return &preconnectedBrokerConnector{first: preconnected, fallback: connector}
196+}
@@ -0,0 +1,196 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/confirm"
13+ "github.com/bnema/vev/pkg/rawterm"
14+)
15+
16+// terminalIsInteractive reports whether the prompt can read its answer from a
17+// console. The client terminal's input has to be a real terminal file: pipes,
18+// redirected files, and detached readers are not consoles, so the prompt
19+// never blocks on a stream nobody reads from the keyboard.
20+func terminalIsInteractive(terminal ports.Terminal) bool {
21+ if terminal == nil {
22+ return false
23+ }
24+ file, ok := terminal.In().(*os.File)
25+ return ok && rawterm.IsTerminal(int(file.Fd()))
26+}
27+
28+// attachInteractiveConsole is the composition-root seam for the console
29+// decision. Production probes the real terminal file; tests inject the
30+// decision so the prompt matrix runs without owning a real TTY.
31+var attachInteractiveConsole = terminalIsInteractive
32+
33+// flushingWriter pushes every prompt write to the console before the
34+// confirmer blocks on the answer. Terminal output is buffered, so without
35+// this flush the user waits in front of a blank console until the client
36+// repaints.
37+type flushingWriter struct {
38+ out io.Writer
39+ flush func() error
40+}
41+
42+func (w flushingWriter) Write(data []byte) (int, error) {
43+ written, err := w.out.Write(data)
44+ if err != nil {
45+ return written, err
46+ }
47+ if err := w.flush(); err != nil {
48+ return written, err
49+ }
50+ return written, nil
51+}
52+
53+// resolveAttachCreationIntent decides the attach intent for one direct exact
54+// `attach <name>` target (local when remoteTarget is empty, otherwise a
55+// configured remote host) before the client owns any console reader or raw
56+// mode. It reuses the broker's own attach resolution (the same exact-target
57+// lookup `localExactAttachResolver`/`remoteExactAttachResolver` run later) so
58+// existence is decided from one committed broker publication, never from
59+// error-text matching.
60+//
61+// When the session exists, the host is not configured, or existence cannot be
62+// determined (the broker is unreachable), it keeps IntentAttach unchanged and
63+// the resolver's own rejection, if any, surfaces unchanged later. When the
64+// session is confirmed absent on a known host, it offers creation on an
65+// interactive console and returns IntentNew on confirmation; a declined
66+// answer, an unusable console, or a non-interactive console all keep
67+// IntentAttach so the existing resolver reports its own clear refusal.
68+//
69+// The check runs before Supervisor.Run enters raw mode, so the create prompt
70+// is always the sole console reader: no attachment worker can leave a
71+// blocked input-pump read behind to steal the answer.
72+//
73+// When the peek connection succeeds, it is returned so the caller can hand it
74+// to a preconnectedBrokerConnector instead of dialing the broker a second
75+// time: an ordinary attach, confirmed or declined, still opens exactly one
76+// broker connection.
77+func resolveAttachCreationIntent(ctx context.Context, intent uint8, name, remoteTarget string, terminal ports.Terminal) (uint8, ports.BrokerService, error) {
78+ if intent != protocol.IntentAttach || name == "" {
79+ return intent, nil, nil
80+ }
81+ service, err := connectProductionClientBroker(ctx)
82+ if err != nil {
83+ // Existence could not be determined: keep the plain attach path and let
84+ // the resolver's own refusal (or the ordinary connector's own retry
85+ // cadence) decide, exactly as if this preflight had never run.
86+ return protocol.IntentAttach, nil, nil
87+ }
88+ exists, hostKnown := attachTargetSessionExists(service.Snapshot(), remoteTarget, name)
89+ if exists || !hostKnown {
90+ return protocol.IntentAttach, service, nil
91+ }
92+ resolved, err := confirmMissingSessionCreate(ctx, name, terminal)
93+ if err != nil {
94+ _ = service.Close()
95+ return protocol.IntentAttach, nil, err
96+ }
97+ return resolved, service, nil
98+}
99+
100+// attachTargetSessionExists reports whether one exact-name attach target is
101+// present in the committed publication, and whether the target's daemon (the
102+// local daemon, or the named remote host) is even a configured observation.
103+// hostKnown is false only when the daemon itself is absent from the snapshot;
104+// a known daemon with no matching session name reports exists=false,
105+// hostKnown=true.
106+func attachTargetSessionExists(snapshot ports.BrokerSnapshot, remoteTarget, name string) (exists, hostKnown bool) {
107+ var observation ports.BrokerDaemonObservation
108+ var ok bool
109+ if remoteTarget == "" {
110+ observation, ok = localBrokerObservation(snapshot)
111+ } else {
112+ observation, ok = snapshot.Find(remoteTarget)
113+ }
114+ if !ok {
115+ return false, false
116+ }
117+ _, found := brokerObservationExactTarget(observation, name)
118+ return found, true
119+}
120+
121+// confirmMissingSessionCreate prompts to create an absent session and maps
122+// the answer to the attach intent: IntentNew on confirmation, IntentAttach on
123+// decline. A cancelled wait surfaces the cancellation error. Non-interactive
124+// consoles, a declined answer, or unreadable input keep IntentAttach. Only
125+// y/yes answers confirm; empty, no, and unknown answers decline.
126+func confirmMissingSessionCreate(ctx context.Context, name string, terminal ports.Terminal) (uint8, error) {
127+ if err := ctx.Err(); err != nil {
128+ return protocol.IntentAttach, err
129+ }
130+ if name == "" {
131+ return protocol.IntentAttach, nil
132+ }
133+ if !attachInteractiveConsole(terminal) {
134+ return protocol.IntentAttach, nil
135+ }
136+ in, out := terminal.In(), terminal.Out()
137+ if in == nil || out == nil {
138+ return protocol.IntentAttach, nil
139+ }
140+ prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush})
141+ create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name))
142+ if err != nil {
143+ if ctxErr := ctx.Err(); ctxErr != nil {
144+ return protocol.IntentAttach, ctxErr
145+ }
146+ // A console that cannot show the question must not consume an answer:
147+ // keep the plain attach path and let the resolver decide.
148+ return protocol.IntentAttach, nil
149+ }
150+ if !create {
151+ return protocol.IntentAttach, nil
152+ }
153+ if err := ctx.Err(); err != nil {
154+ return protocol.IntentAttach, err
155+ }
156+ return protocol.IntentNew, nil
157+}
158+
159+// preconnectedBrokerConnector hands one already-connected broker service to
160+// the caller's first Connect call, then falls back to the wrapped connector
161+// for every later attempt (the supervisor's own reconnect cadence). It exists
162+// only to avoid wasting the connection the attach-creation preflight already
163+// established: the supervisor still owns and closes whatever it adopts.
164+type preconnectedBrokerConnector struct {
165+ first ports.BrokerService
166+ fallback ports.BrokerConnector
167+
168+ mu sync.Mutex
169+ used bool
170+}
171+
172+func (c *preconnectedBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
173+ c.mu.Lock()
174+ if !c.used {
175+ c.used = true
176+ first := c.first
177+ c.mu.Unlock()
178+ if first != nil {
179+ return first, nil
180+ }
181+ } else {
182+ c.mu.Unlock()
183+ }
184+ return c.fallback.Connect(ctx)
185+}
186+
187+var _ ports.BrokerConnector = (*preconnectedBrokerConnector)(nil)
188+
189+// withPreconnected wraps connector to serve preconnected as its first
190+// connection, or returns connector unchanged when preconnected is nil.
191+func withPreconnected(connector ports.BrokerConnector, preconnected ports.BrokerService) ports.BrokerConnector {
192+ if preconnected == nil {
193+ return connector
194+ }
195+ return &preconnectedBrokerConnector{first: preconnected, fallback: connector}
196+}
A internal/app/attach_missing_prompt_test.go +480 -0
@@ -0,0 +1,480 @@
1+package app
2+
3+// Attach-missing create prompt (Plan 003 E4).
4+//
5+// resolveAttachCreationIntent restores the `vev attach <missing>` create
6+// prompt main once ran through the direct-dial preflight, but through the
7+// broker: it peeks the first committed publication over the same
8+// connectProductionClientBroker seam the ordinary terminal composition uses,
9+// decides existence with the exact lookup the later resolver also runs
10+// (attachTargetSessionExists / brokerObservationExactTarget), and on a
11+// confirmed create hands its already-open connection back so the run still
12+// opens exactly one broker connection.
13+
14+import (
15+ "context"
16+ "errors"
17+ "io"
18+ "os"
19+ "strings"
20+ "syscall"
21+ "testing"
22+ "time"
23+
24+ "github.com/stretchr/testify/require"
25+
26+ "github.com/bnema/vev/internal/adapters/term"
27+ "github.com/bnema/vev/internal/ports"
28+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
29+ "github.com/bnema/vev/internal/protocol"
30+ "github.com/bnema/vev/pkg/rawterm"
31+)
32+
33+// interactiveProbe injects the console decision, standing in for the
34+// production probe so the prompt matrix runs without owning a real TTY.
35+func interactiveProbe(interactive bool) func(ports.Terminal) bool {
36+ return func(ports.Terminal) bool { return interactive }
37+}
38+
39+// withAttachInteractiveConsole installs a scripted console decision for the
40+// duration of a test.
41+func withAttachInteractiveConsole(t *testing.T, interactive bool) {
42+ t.Helper()
43+ previous := attachInteractiveConsole
44+ attachInteractiveConsole = interactiveProbe(interactive)
45+ t.Cleanup(func() { attachInteractiveConsole = previous })
46+}
47+
48+// withPreflightBroker installs a scripted connectProductionClientBroker seam
49+// for the duration of a test, exactly like the terminal composition tests in
50+// broker_client_test.go, so the preflight peek never reaches a production
51+// broker.
52+func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) {
53+ t.Helper()
54+ previous := connectProductionClientBroker
55+ connectProductionClientBroker = connect
56+ t.Cleanup(func() { connectProductionClientBroker = previous })
57+}
58+
59+func TestAttachTargetSessionExists(t *testing.T) {
60+ tests := []struct {
61+ name string
62+ snapshot ports.BrokerSnapshot
63+ remote string
64+ target string
65+ wantExists bool
66+ wantHostKnow bool
67+ }{
68+ {
69+ name: "local session present",
70+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
71+ target: "work",
72+ wantExists: true,
73+ wantHostKnow: true,
74+ },
75+ {
76+ name: "local session absent",
77+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
78+ target: "gone",
79+ wantExists: false,
80+ wantHostKnow: true,
81+ },
82+ {
83+ name: "local daemon absent from snapshot",
84+ snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1},
85+ target: "gone",
86+ wantExists: false,
87+ wantHostKnow: false,
88+ },
89+ {
90+ name: "remote session present",
91+ snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
92+ remote: "user@example.test",
93+ target: "work",
94+ wantExists: true,
95+ wantHostKnow: true,
96+ },
97+ {
98+ name: "remote session absent on known host",
99+ snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
100+ remote: "user@example.test",
101+ target: "gone",
102+ wantExists: false,
103+ wantHostKnow: true,
104+ },
105+ {
106+ name: "remote host not configured",
107+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
108+ remote: "user@elsewhere.test",
109+ target: "work",
110+ wantExists: false,
111+ wantHostKnow: false,
112+ },
113+ }
114+ for _, tc := range tests {
115+ t.Run(tc.name, func(t *testing.T) {
116+ exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target)
117+ require.Equal(t, tc.wantExists, exists)
118+ require.Equal(t, tc.wantHostKnow, hostKnown)
119+ })
120+ }
121+}
122+
123+func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) {
124+ for _, tc := range []struct {
125+ name string
126+ intent uint8
127+ target string
128+ }{
129+ {name: "create", intent: protocol.IntentNew, target: "scratch"},
130+ {name: "ephemeral", intent: protocol.IntentEphemeral, target: ""},
131+ {name: "attach with no name", intent: protocol.IntentAttach, target: ""},
132+ } {
133+ t.Run(tc.name, func(t *testing.T) {
134+ connected := false
135+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
136+ connected = true
137+ return nil, errors.New("must not connect")
138+ })
139+ intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil)
140+ require.NoError(t, err)
141+ require.Equal(t, tc.intent, intent)
142+ require.Nil(t, service)
143+ require.False(t, connected, "a non-attach or nameless intent never peeks the broker")
144+ })
145+ }
146+}
147+
148+func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
149+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
150+ return nil, errors.New("broker unreachable")
151+ })
152+ intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil)
153+ require.NoError(t, err)
154+ require.Equal(t, protocol.IntentAttach, intent)
155+ require.Nil(t, service, "an undeterminable preflight must not hand back a connection")
156+}
157+
158+func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
159+ service := portsmocks.NewMockBrokerService(t)
160+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
161+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
162+ withAttachInteractiveConsole(t, true) // must not even be consulted
163+
164+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil)
165+ require.NoError(t, err)
166+ require.Equal(t, protocol.IntentAttach, intent)
167+ require.Same(t, service, got, "the peeked connection is handed back for reuse")
168+}
169+
170+func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) {
171+ service := portsmocks.NewMockBrokerService(t)
172+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
173+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
174+ withAttachInteractiveConsole(t, true)
175+
176+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil)
177+ require.NoError(t, err)
178+ require.Equal(t, protocol.IntentAttach, intent)
179+ require.Same(t, service, got)
180+}
181+
182+func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
183+ tests := []struct {
184+ name string
185+ remote string
186+ answer string
187+ interactive bool
188+ wantIntent uint8
189+ wantPromptPart string
190+ wantNoPrompt bool
191+ }{
192+ {name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`},
193+ {name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"},
194+ {name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
195+ {name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"},
196+ {name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
197+ {name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`},
198+ }
199+ for _, tc := range tests {
200+ t.Run(tc.name, func(t *testing.T) {
201+ var snapshot ports.BrokerSnapshot
202+ if tc.remote == "" {
203+ snapshot = terminalCompositionSnapshot([]string{"work"}, nil)
204+ } else {
205+ snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}})
206+ }
207+ service := portsmocks.NewMockBrokerService(t)
208+ service.EXPECT().Snapshot().Return(snapshot).Once()
209+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
210+ withAttachInteractiveConsole(t, tc.interactive)
211+
212+ terminal := portsmocks.NewMockTerminal(t)
213+ var promptOut strings.Builder
214+ terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
215+ terminal.EXPECT().Out().Return(&promptOut).Maybe()
216+ terminal.EXPECT().Flush().Return(nil).Maybe()
217+
218+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal)
219+ require.NoError(t, err)
220+ require.Equal(t, tc.wantIntent, intent)
221+ require.Same(t, service, got, "the peeked connection is always handed back on a definite answer")
222+ if tc.wantPromptPart != "" {
223+ require.Contains(t, promptOut.String(), tc.wantPromptPart)
224+ }
225+ if tc.wantNoPrompt {
226+ require.Empty(t, promptOut.String())
227+ }
228+ })
229+ }
230+}
231+
232+func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
233+ service := portsmocks.NewMockBrokerService(t)
234+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
235+ service.EXPECT().Close().Return(nil).Once()
236+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
237+ withAttachInteractiveConsole(t, true)
238+
239+ terminal := portsmocks.NewMockTerminal(t)
240+ reader, writer := io.Pipe()
241+ t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() })
242+ terminal.EXPECT().In().Return(reader).Maybe()
243+ terminal.EXPECT().Out().Return(io.Discard).Maybe()
244+ terminal.EXPECT().Flush().Return(nil).Maybe()
245+
246+ ctx, cancel := context.WithCancel(context.Background())
247+ done := make(chan struct {
248+ intent uint8
249+ service ports.BrokerService
250+ err error
251+ }, 1)
252+ go func() {
253+ intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal)
254+ done <- struct {
255+ intent uint8
256+ service ports.BrokerService
257+ err error
258+ }{intent, got, err}
259+ }()
260+ cancel()
261+ select {
262+ case result := <-done:
263+ require.ErrorIs(t, result.err, context.Canceled)
264+ require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection")
265+ case <-time.After(5 * time.Second):
266+ t.Fatal("resolveAttachCreationIntent did not release on cancellation")
267+ }
268+}
269+
270+func TestConfirmMissingSessionCreate(t *testing.T) {
271+ tests := []struct {
272+ name string
273+ answer string
274+ interactive bool
275+ wantIntent uint8
276+ wantPromptPart string
277+ wantNoPrompt bool
278+ }{
279+ {name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`},
280+ {name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew},
281+ {name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach},
282+ {name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
283+ {name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach},
284+ {name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
285+ }
286+ for _, tc := range tests {
287+ t.Run(tc.name, func(t *testing.T) {
288+ withAttachInteractiveConsole(t, tc.interactive)
289+ terminal := portsmocks.NewMockTerminal(t)
290+ var out strings.Builder
291+ terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
292+ terminal.EXPECT().Out().Return(&out).Maybe()
293+ terminal.EXPECT().Flush().Return(nil).Maybe()
294+
295+ intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
296+ require.NoError(t, err)
297+ require.Equal(t, tc.wantIntent, intent)
298+ if tc.wantPromptPart != "" {
299+ require.Contains(t, out.String(), tc.wantPromptPart)
300+ }
301+ if tc.wantNoPrompt {
302+ require.Empty(t, out.String())
303+ }
304+ })
305+ }
306+}
307+
308+// TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot
309+// show the question: the prompt must keep the plain attach path instead of
310+// consuming an answer nobody was asked for. The input blocks forever, so a
311+// prompt that reads it would only return on context expiry.
312+func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) {
313+ closedOut, err := os.CreateTemp(t.TempDir(), "vev-console")
314+ require.NoError(t, err)
315+ require.NoError(t, closedOut.Close())
316+
317+ for _, tc := range []struct {
318+ name string
319+ out io.Writer
320+ flushErr error
321+ }{
322+ {name: "write fails", out: closedOut},
323+ {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")},
324+ } {
325+ t.Run(tc.name, func(t *testing.T) {
326+ withAttachInteractiveConsole(t, true)
327+ consoleIn, consoleOut, err := os.Pipe()
328+ require.NoError(t, err)
329+ t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() })
330+
331+ terminal := portsmocks.NewMockTerminal(t)
332+ terminal.EXPECT().In().Return(consoleIn).Maybe()
333+ terminal.EXPECT().Out().Return(tc.out).Maybe()
334+ terminal.EXPECT().Flush().Return(tc.flushErr).Maybe()
335+
336+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
337+ defer cancel()
338+ intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal)
339+ require.NoError(t, err, "an unusable console must not fail the attach")
340+ require.Equal(t, protocol.IntentAttach, intent)
341+ })
342+ }
343+}
344+
345+// TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal
346+// adapter, whose output is buffered: the prompt has to flush the question
347+// before it blocks on the answer, or the user waits in front of a blank
348+// console and the question only shows up after the client repaints.
349+func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) {
350+ withAttachInteractiveConsole(t, true)
351+ consoleIn, answers, err := os.Pipe()
352+ require.NoError(t, err)
353+ questions, consoleOut, err := os.Pipe()
354+ require.NoError(t, err)
355+ t.Cleanup(func() {
356+ _ = consoleIn.Close()
357+ _ = answers.Close()
358+ _ = questions.Close()
359+ _ = consoleOut.Close()
360+ })
361+
362+ terminal := term.NewWithFiles(consoleIn, consoleOut)
363+ done := make(chan struct {
364+ intent uint8
365+ err error
366+ }, 1)
367+ go func() {
368+ intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
369+ done <- struct {
370+ intent uint8
371+ err error
372+ }{intent, err}
373+ }()
374+
375+ question := readAttachPrompt(t, questions)
376+ require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer")
377+
378+ _, err = answers.WriteString("y\n")
379+ require.NoError(t, err)
380+ select {
381+ case result := <-done:
382+ require.NoError(t, result.err)
383+ require.Equal(t, protocol.IntentNew, result.intent)
384+ case <-time.After(5 * time.Second):
385+ t.Fatal("the prompt did not release after the answer")
386+ }
387+}
388+
389+// readAttachPrompt returns the console output that ends the create question,
390+// failing the test instead of hanging when nothing arrives.
391+func readAttachPrompt(t *testing.T, console *os.File) string {
392+ t.Helper()
393+ require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second)))
394+ var output []byte
395+ frame := make([]byte, 64)
396+ for {
397+ read, err := console.Read(frame)
398+ output = append(output, frame[:read]...)
399+ if strings.Contains(string(output), "[y/N] ") {
400+ return string(output)
401+ }
402+ if err != nil {
403+ t.Fatalf("prompt never reached the console (read %q: %v)", output, err)
404+ }
405+ }
406+}
407+
408+// TestTerminalIsInteractive covers the production probe on both sides of the
409+// console check: a real terminal file and the streams that are not consoles.
410+func TestTerminalIsInteractive(t *testing.T) {
411+ require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt")
412+ pty := openAttachPtySlave(t)
413+ require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console")
414+
415+ console, writer, err := os.Pipe()
416+ require.NoError(t, err)
417+ t.Cleanup(func() { _ = console.Close(); _ = writer.Close() })
418+
419+ for _, tc := range []struct {
420+ name string
421+ in io.Reader
422+ }{
423+ {name: "detached reader", in: strings.NewReader("y\n")},
424+ {name: "non-terminal file", in: console},
425+ } {
426+ t.Run(tc.name, func(t *testing.T) {
427+ terminal := portsmocks.NewMockTerminal(t)
428+ terminal.EXPECT().In().Return(tc.in).Maybe()
429+ require.False(t, terminalIsInteractive(terminal))
430+ })
431+ }
432+}
433+
434+// openAttachPtySlave returns the slave side of a real PTY pair: the only way
435+// to hand the probe an *os.File the kernel reports as a terminal.
436+func openAttachPtySlave(t *testing.T) *os.File {
437+ t.Helper()
438+ master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
439+ if err != nil {
440+ t.Skipf("open /dev/ptmx: %v", err)
441+ }
442+ t.Cleanup(func() { _ = master.Close() })
443+ slave, err := rawterm.PreparePty(int(master.Fd()))
444+ require.NoError(t, err, "prepare pty")
445+ t.Cleanup(func() { _ = slave.Close() })
446+ return slave
447+}
448+
449+func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) {
450+ first := portsmocks.NewMockBrokerService(t)
451+ second := portsmocks.NewMockBrokerService(t)
452+ fallbackCalls := 0
453+ fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) {
454+ fallbackCalls++
455+ return second, nil
456+ })
457+ connector := withPreconnected(fallback, first)
458+
459+ got, err := connector.Connect(context.Background())
460+ require.NoError(t, err)
461+ require.Same(t, first, got, "the first Connect reuses the preflight's own connection")
462+ require.Zero(t, fallbackCalls)
463+
464+ got, err = connector.Connect(context.Background())
465+ require.NoError(t, err)
466+ require.Same(t, second, got, "a later Connect reconnects through the fallback")
467+ require.Equal(t, 1, fallbackCalls)
468+}
469+
470+func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) {
471+ fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { return nil, nil })
472+ got := withPreconnected(fallback, nil)
473+ service, err := got.Connect(context.Background())
474+ require.NoError(t, err)
475+ require.Nil(t, service)
476+}
477+
478+type brokerConnectorFunc func(context.Context) (ports.BrokerService, error)
479+
480+func (f brokerConnectorFunc) Connect(ctx context.Context) (ports.BrokerService, error) { return f(ctx) }
@@ -0,0 +1,480 @@
1+package app
2+
3+// Attach-missing create prompt (Plan 003 E4).
4+//
5+// resolveAttachCreationIntent restores the `vev attach <missing>` create
6+// prompt main once ran through the direct-dial preflight, but through the
7+// broker: it peeks the first committed publication over the same
8+// connectProductionClientBroker seam the ordinary terminal composition uses,
9+// decides existence with the exact lookup the later resolver also runs
10+// (attachTargetSessionExists / brokerObservationExactTarget), and on a
11+// confirmed create hands its already-open connection back so the run still
12+// opens exactly one broker connection.
13+
14+import (
15+ "context"
16+ "errors"
17+ "io"
18+ "os"
19+ "strings"
20+ "syscall"
21+ "testing"
22+ "time"
23+
24+ "github.com/stretchr/testify/require"
25+
26+ "github.com/bnema/vev/internal/adapters/term"
27+ "github.com/bnema/vev/internal/ports"
28+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
29+ "github.com/bnema/vev/internal/protocol"
30+ "github.com/bnema/vev/pkg/rawterm"
31+)
32+
33+// interactiveProbe injects the console decision, standing in for the
34+// production probe so the prompt matrix runs without owning a real TTY.
35+func interactiveProbe(interactive bool) func(ports.Terminal) bool {
36+ return func(ports.Terminal) bool { return interactive }
37+}
38+
39+// withAttachInteractiveConsole installs a scripted console decision for the
40+// duration of a test.
41+func withAttachInteractiveConsole(t *testing.T, interactive bool) {
42+ t.Helper()
43+ previous := attachInteractiveConsole
44+ attachInteractiveConsole = interactiveProbe(interactive)
45+ t.Cleanup(func() { attachInteractiveConsole = previous })
46+}
47+
48+// withPreflightBroker installs a scripted connectProductionClientBroker seam
49+// for the duration of a test, exactly like the terminal composition tests in
50+// broker_client_test.go, so the preflight peek never reaches a production
51+// broker.
52+func withPreflightBroker(t *testing.T, connect func(context.Context) (ports.BrokerService, error)) {
53+ t.Helper()
54+ previous := connectProductionClientBroker
55+ connectProductionClientBroker = connect
56+ t.Cleanup(func() { connectProductionClientBroker = previous })
57+}
58+
59+func TestAttachTargetSessionExists(t *testing.T) {
60+ tests := []struct {
61+ name string
62+ snapshot ports.BrokerSnapshot
63+ remote string
64+ target string
65+ wantExists bool
66+ wantHostKnow bool
67+ }{
68+ {
69+ name: "local session present",
70+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
71+ target: "work",
72+ wantExists: true,
73+ wantHostKnow: true,
74+ },
75+ {
76+ name: "local session absent",
77+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
78+ target: "gone",
79+ wantExists: false,
80+ wantHostKnow: true,
81+ },
82+ {
83+ name: "local daemon absent from snapshot",
84+ snapshot: ports.BrokerSnapshot{Epoch: 1, Revision: 1},
85+ target: "gone",
86+ wantExists: false,
87+ wantHostKnow: false,
88+ },
89+ {
90+ name: "remote session present",
91+ snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
92+ remote: "user@example.test",
93+ target: "work",
94+ wantExists: true,
95+ wantHostKnow: true,
96+ },
97+ {
98+ name: "remote session absent on known host",
99+ snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}}),
100+ remote: "user@example.test",
101+ target: "gone",
102+ wantExists: false,
103+ wantHostKnow: true,
104+ },
105+ {
106+ name: "remote host not configured",
107+ snapshot: terminalCompositionSnapshot([]string{"work"}, nil),
108+ remote: "user@elsewhere.test",
109+ target: "work",
110+ wantExists: false,
111+ wantHostKnow: false,
112+ },
113+ }
114+ for _, tc := range tests {
115+ t.Run(tc.name, func(t *testing.T) {
116+ exists, hostKnown := attachTargetSessionExists(tc.snapshot, tc.remote, tc.target)
117+ require.Equal(t, tc.wantExists, exists)
118+ require.Equal(t, tc.wantHostKnow, hostKnown)
119+ })
120+ }
121+}
122+
123+func TestResolveAttachCreationIntentSkipsNonAttach(t *testing.T) {
124+ for _, tc := range []struct {
125+ name string
126+ intent uint8
127+ target string
128+ }{
129+ {name: "create", intent: protocol.IntentNew, target: "scratch"},
130+ {name: "ephemeral", intent: protocol.IntentEphemeral, target: ""},
131+ {name: "attach with no name", intent: protocol.IntentAttach, target: ""},
132+ } {
133+ t.Run(tc.name, func(t *testing.T) {
134+ connected := false
135+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
136+ connected = true
137+ return nil, errors.New("must not connect")
138+ })
139+ intent, service, err := resolveAttachCreationIntent(context.Background(), tc.intent, tc.target, "", nil)
140+ require.NoError(t, err)
141+ require.Equal(t, tc.intent, intent)
142+ require.Nil(t, service)
143+ require.False(t, connected, "a non-attach or nameless intent never peeks the broker")
144+ })
145+ }
146+}
147+
148+func TestResolveAttachCreationIntentBrokerUnavailableKeepsAttach(t *testing.T) {
149+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) {
150+ return nil, errors.New("broker unreachable")
151+ })
152+ intent, service, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "scratch", "", nil)
153+ require.NoError(t, err)
154+ require.Equal(t, protocol.IntentAttach, intent)
155+ require.Nil(t, service, "an undeterminable preflight must not hand back a connection")
156+}
157+
158+func TestResolveAttachCreationIntentExistingSessionAttachesWithoutPrompt(t *testing.T) {
159+ service := portsmocks.NewMockBrokerService(t)
160+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
161+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
162+ withAttachInteractiveConsole(t, true) // must not even be consulted
163+
164+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "", nil)
165+ require.NoError(t, err)
166+ require.Equal(t, protocol.IntentAttach, intent)
167+ require.Same(t, service, got, "the peeked connection is handed back for reuse")
168+}
169+
170+func TestResolveAttachCreationIntentUnknownHostAttachesWithoutPrompt(t *testing.T) {
171+ service := portsmocks.NewMockBrokerService(t)
172+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
173+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
174+ withAttachInteractiveConsole(t, true)
175+
176+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "work", "user@elsewhere.test", nil)
177+ require.NoError(t, err)
178+ require.Equal(t, protocol.IntentAttach, intent)
179+ require.Same(t, service, got)
180+}
181+
182+func TestResolveAttachCreationIntentMissingSessionPrompt(t *testing.T) {
183+ tests := []struct {
184+ name string
185+ remote string
186+ answer string
187+ interactive bool
188+ wantIntent uint8
189+ wantPromptPart string
190+ wantNoPrompt bool
191+ }{
192+ {name: "local confirm creates", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "gone" doesn't exist, want to create and attach to it? [y/N]`},
193+ {name: "local decline attaches", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "gone"},
194+ {name: "local empty answer attaches", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
195+ {name: "local unknown answer attaches", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"},
196+ {name: "non-interactive console attaches without prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
197+ {name: "remote confirm creates", remote: "user@example.test", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "gone" doesn't exist`},
198+ }
199+ for _, tc := range tests {
200+ t.Run(tc.name, func(t *testing.T) {
201+ var snapshot ports.BrokerSnapshot
202+ if tc.remote == "" {
203+ snapshot = terminalCompositionSnapshot([]string{"work"}, nil)
204+ } else {
205+ snapshot = terminalCompositionSnapshot(nil, map[string][]string{tc.remote: {"work"}})
206+ }
207+ service := portsmocks.NewMockBrokerService(t)
208+ service.EXPECT().Snapshot().Return(snapshot).Once()
209+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
210+ withAttachInteractiveConsole(t, tc.interactive)
211+
212+ terminal := portsmocks.NewMockTerminal(t)
213+ var promptOut strings.Builder
214+ terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
215+ terminal.EXPECT().Out().Return(&promptOut).Maybe()
216+ terminal.EXPECT().Flush().Return(nil).Maybe()
217+
218+ intent, got, err := resolveAttachCreationIntent(context.Background(), protocol.IntentAttach, "gone", tc.remote, terminal)
219+ require.NoError(t, err)
220+ require.Equal(t, tc.wantIntent, intent)
221+ require.Same(t, service, got, "the peeked connection is always handed back on a definite answer")
222+ if tc.wantPromptPart != "" {
223+ require.Contains(t, promptOut.String(), tc.wantPromptPart)
224+ }
225+ if tc.wantNoPrompt {
226+ require.Empty(t, promptOut.String())
227+ }
228+ })
229+ }
230+}
231+
232+func TestResolveAttachCreationIntentCancelledPromptClosesThePeek(t *testing.T) {
233+ service := portsmocks.NewMockBrokerService(t)
234+ service.EXPECT().Snapshot().Return(terminalCompositionSnapshot([]string{"work"}, nil)).Once()
235+ service.EXPECT().Close().Return(nil).Once()
236+ withPreflightBroker(t, func(context.Context) (ports.BrokerService, error) { return service, nil })
237+ withAttachInteractiveConsole(t, true)
238+
239+ terminal := portsmocks.NewMockTerminal(t)
240+ reader, writer := io.Pipe()
241+ t.Cleanup(func() { _ = reader.Close(); _ = writer.Close() })
242+ terminal.EXPECT().In().Return(reader).Maybe()
243+ terminal.EXPECT().Out().Return(io.Discard).Maybe()
244+ terminal.EXPECT().Flush().Return(nil).Maybe()
245+
246+ ctx, cancel := context.WithCancel(context.Background())
247+ done := make(chan struct {
248+ intent uint8
249+ service ports.BrokerService
250+ err error
251+ }, 1)
252+ go func() {
253+ intent, got, err := resolveAttachCreationIntent(ctx, protocol.IntentAttach, "gone", "", terminal)
254+ done <- struct {
255+ intent uint8
256+ service ports.BrokerService
257+ err error
258+ }{intent, got, err}
259+ }()
260+ cancel()
261+ select {
262+ case result := <-done:
263+ require.ErrorIs(t, result.err, context.Canceled)
264+ require.Nil(t, result.service, "a cancelled prompt closes and drops the peeked connection")
265+ case <-time.After(5 * time.Second):
266+ t.Fatal("resolveAttachCreationIntent did not release on cancellation")
267+ }
268+}
269+
270+func TestConfirmMissingSessionCreate(t *testing.T) {
271+ tests := []struct {
272+ name string
273+ answer string
274+ interactive bool
275+ wantIntent uint8
276+ wantPromptPart string
277+ wantNoPrompt bool
278+ }{
279+ {name: "accepts yes", answer: "y\n", interactive: true, wantIntent: protocol.IntentNew, wantPromptPart: `session "scratch" doesn't exist, want to create and attach to it? [y/N]`},
280+ {name: "accepts yes word", answer: "yes\n", interactive: true, wantIntent: protocol.IntentNew},
281+ {name: "declines no", answer: "n\n", interactive: true, wantIntent: protocol.IntentAttach},
282+ {name: "defaults no on empty answer", answer: "\n", interactive: true, wantIntent: protocol.IntentAttach},
283+ {name: "defaults no on unknown answer", answer: "later\n", interactive: true, wantIntent: protocol.IntentAttach},
284+ {name: "non-interactive console skips the prompt", answer: "y\n", interactive: false, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
285+ }
286+ for _, tc := range tests {
287+ t.Run(tc.name, func(t *testing.T) {
288+ withAttachInteractiveConsole(t, tc.interactive)
289+ terminal := portsmocks.NewMockTerminal(t)
290+ var out strings.Builder
291+ terminal.EXPECT().In().Return(strings.NewReader(tc.answer)).Maybe()
292+ terminal.EXPECT().Out().Return(&out).Maybe()
293+ terminal.EXPECT().Flush().Return(nil).Maybe()
294+
295+ intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
296+ require.NoError(t, err)
297+ require.Equal(t, tc.wantIntent, intent)
298+ if tc.wantPromptPart != "" {
299+ require.Contains(t, out.String(), tc.wantPromptPart)
300+ }
301+ if tc.wantNoPrompt {
302+ require.Empty(t, out.String())
303+ }
304+ })
305+ }
306+}
307+
308+// TestConfirmMissingSessionCreateFailureAttaches covers a console that cannot
309+// show the question: the prompt must keep the plain attach path instead of
310+// consuming an answer nobody was asked for. The input blocks forever, so a
311+// prompt that reads it would only return on context expiry.
312+func TestConfirmMissingSessionCreateFailureAttaches(t *testing.T) {
313+ closedOut, err := os.CreateTemp(t.TempDir(), "vev-console")
314+ require.NoError(t, err)
315+ require.NoError(t, closedOut.Close())
316+
317+ for _, tc := range []struct {
318+ name string
319+ out io.Writer
320+ flushErr error
321+ }{
322+ {name: "write fails", out: closedOut},
323+ {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")},
324+ } {
325+ t.Run(tc.name, func(t *testing.T) {
326+ withAttachInteractiveConsole(t, true)
327+ consoleIn, consoleOut, err := os.Pipe()
328+ require.NoError(t, err)
329+ t.Cleanup(func() { _ = consoleIn.Close(); _ = consoleOut.Close() })
330+
331+ terminal := portsmocks.NewMockTerminal(t)
332+ terminal.EXPECT().In().Return(consoleIn).Maybe()
333+ terminal.EXPECT().Out().Return(tc.out).Maybe()
334+ terminal.EXPECT().Flush().Return(tc.flushErr).Maybe()
335+
336+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
337+ defer cancel()
338+ intent, err := confirmMissingSessionCreate(ctx, "scratch", terminal)
339+ require.NoError(t, err, "an unusable console must not fail the attach")
340+ require.Equal(t, protocol.IntentAttach, intent)
341+ })
342+ }
343+}
344+
345+// TestConfirmMissingSessionCreateFlushesQuestion drives the real terminal
346+// adapter, whose output is buffered: the prompt has to flush the question
347+// before it blocks on the answer, or the user waits in front of a blank
348+// console and the question only shows up after the client repaints.
349+func TestConfirmMissingSessionCreateFlushesQuestion(t *testing.T) {
350+ withAttachInteractiveConsole(t, true)
351+ consoleIn, answers, err := os.Pipe()
352+ require.NoError(t, err)
353+ questions, consoleOut, err := os.Pipe()
354+ require.NoError(t, err)
355+ t.Cleanup(func() {
356+ _ = consoleIn.Close()
357+ _ = answers.Close()
358+ _ = questions.Close()
359+ _ = consoleOut.Close()
360+ })
361+
362+ terminal := term.NewWithFiles(consoleIn, consoleOut)
363+ done := make(chan struct {
364+ intent uint8
365+ err error
366+ }, 1)
367+ go func() {
368+ intent, err := confirmMissingSessionCreate(context.Background(), "scratch", terminal)
369+ done <- struct {
370+ intent uint8
371+ err error
372+ }{intent, err}
373+ }()
374+
375+ question := readAttachPrompt(t, questions)
376+ require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the prompt reads an answer")
377+
378+ _, err = answers.WriteString("y\n")
379+ require.NoError(t, err)
380+ select {
381+ case result := <-done:
382+ require.NoError(t, result.err)
383+ require.Equal(t, protocol.IntentNew, result.intent)
384+ case <-time.After(5 * time.Second):
385+ t.Fatal("the prompt did not release after the answer")
386+ }
387+}
388+
389+// readAttachPrompt returns the console output that ends the create question,
390+// failing the test instead of hanging when nothing arrives.
391+func readAttachPrompt(t *testing.T, console *os.File) string {
392+ t.Helper()
393+ require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second)))
394+ var output []byte
395+ frame := make([]byte, 64)
396+ for {
397+ read, err := console.Read(frame)
398+ output = append(output, frame[:read]...)
399+ if strings.Contains(string(output), "[y/N] ") {
400+ return string(output)
401+ }
402+ if err != nil {
403+ t.Fatalf("prompt never reached the console (read %q: %v)", output, err)
404+ }
405+ }
406+}
407+
408+// TestTerminalIsInteractive covers the production probe on both sides of the
409+// console check: a real terminal file and the streams that are not consoles.
410+func TestTerminalIsInteractive(t *testing.T) {
411+ require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt")
412+ pty := openAttachPtySlave(t)
413+ require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console")
414+
415+ console, writer, err := os.Pipe()
416+ require.NoError(t, err)
417+ t.Cleanup(func() { _ = console.Close(); _ = writer.Close() })
418+
419+ for _, tc := range []struct {
420+ name string
421+ in io.Reader
422+ }{
423+ {name: "detached reader", in: strings.NewReader("y\n")},
424+ {name: "non-terminal file", in: console},
425+ } {
426+ t.Run(tc.name, func(t *testing.T) {
427+ terminal := portsmocks.NewMockTerminal(t)
428+ terminal.EXPECT().In().Return(tc.in).Maybe()
429+ require.False(t, terminalIsInteractive(terminal))
430+ })
431+ }
432+}
433+
434+// openAttachPtySlave returns the slave side of a real PTY pair: the only way
435+// to hand the probe an *os.File the kernel reports as a terminal.
436+func openAttachPtySlave(t *testing.T) *os.File {
437+ t.Helper()
438+ master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
439+ if err != nil {
440+ t.Skipf("open /dev/ptmx: %v", err)
441+ }
442+ t.Cleanup(func() { _ = master.Close() })
443+ slave, err := rawterm.PreparePty(int(master.Fd()))
444+ require.NoError(t, err, "prepare pty")
445+ t.Cleanup(func() { _ = slave.Close() })
446+ return slave
447+}
448+
449+func TestPreconnectedBrokerConnectorReusesThenFallsBack(t *testing.T) {
450+ first := portsmocks.NewMockBrokerService(t)
451+ second := portsmocks.NewMockBrokerService(t)
452+ fallbackCalls := 0
453+ fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) {
454+ fallbackCalls++
455+ return second, nil
456+ })
457+ connector := withPreconnected(fallback, first)
458+
459+ got, err := connector.Connect(context.Background())
460+ require.NoError(t, err)
461+ require.Same(t, first, got, "the first Connect reuses the preflight's own connection")
462+ require.Zero(t, fallbackCalls)
463+
464+ got, err = connector.Connect(context.Background())
465+ require.NoError(t, err)
466+ require.Same(t, second, got, "a later Connect reconnects through the fallback")
467+ require.Equal(t, 1, fallbackCalls)
468+}
469+
470+func TestWithPreconnectedNilPassesThroughUnchanged(t *testing.T) {
471+ fallback := brokerConnectorFunc(func(context.Context) (ports.BrokerService, error) { return nil, nil })
472+ got := withPreconnected(fallback, nil)
473+ service, err := got.Connect(context.Background())
474+ require.NoError(t, err)
475+ require.Nil(t, service)
476+}
477+
478+type brokerConnectorFunc func(context.Context) (ports.BrokerService, error)
479+
480+func (f brokerConnectorFunc) Connect(ctx context.Context) (ports.BrokerService, error) { return f(ctx) }
M internal/app/run.go +9 -1
@@ -940,13 +940,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
940940 if observer != nil {
941941 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true))
942942 }
943+ terminal := terminalForAttach()
944+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
945+ if err != nil {
946+ return err
947+ }
943948 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
944949 if err != nil {
950+ if preconnected != nil {
951+ _ = preconnected.Close()
952+ }
945953 return err
946954 }
947955 callbacks := terminalBrokerCallbacks()
948956 return runBrokerClient(ctx, brokerClientConfig{
949- Connector: newProductionBrokerConnector(), Terminal: terminalForAttach(), Clock: clk,
957+ Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
950958 InitialNavigation: navigation, ResolveInitialNavigation: resolver,
951959 AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(),
952960 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
@@ -940,13 +940,21 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
940 if observer != nil {940 if observer != nil {
941 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true))941 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true))
942 }942 }
943+ terminal := terminalForAttach()
944+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
945+ if err != nil {
946+ return err
947+ }
943 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)948 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
944 if err != nil {949 if err != nil {
950+ if preconnected != nil {
951+ _ = preconnected.Close()
952+ }
945 return err953 return err
946 }954 }
947 callbacks := terminalBrokerCallbacks()955 callbacks := terminalBrokerCallbacks()
948 return runBrokerClient(ctx, brokerClientConfig{956 return runBrokerClient(ctx, brokerClientConfig{
949- Connector: newProductionBrokerConnector(), Terminal: terminalForAttach(), Clock: clk,957+ Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
950 InitialNavigation: navigation, ResolveInitialNavigation: resolver,958 InitialNavigation: navigation, ResolveInitialNavigation: resolver,
951 AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(),959 AttachmentEnvironment: terminalAttachmentEnvironment(), SessionEnvironment: terminalSessionEnvironment(),
952 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,960 OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
M internal/app/ui_driver.go +10 -2
@@ -471,16 +471,24 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
471471 fmt.Fprintln(os.Stderr, socketPath)
472472 defer func() { _ = endpoint.Close() }()
473473 }
474+ terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
475+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
476+ if err != nil {
477+ return err
478+ }
474479 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
475480 if err != nil {
481+ if preconnected != nil {
482+ _ = preconnected.Close()
483+ }
476484 return err
477485 }
478486 sessionEnv := terminalSessionEnvironment()
479487 attachmentEnv := terminalAttachmentEnvironment()
480488 attachmentEnv.Cwd = sessionEnv.Cwd
481489 return runBrokerClient(ctx, brokerClientConfig{
482- Connector: newProductionBrokerConnector(),
483- Terminal: observedTerminal{Terminal: physical, UIOutputTransaction: mirror},
490+ Connector: withPreconnected(newProductionBrokerConnector(), preconnected),
491+ Terminal: terminal,
484492 Clock: clk,
485493 UI: ui,
486494 InitialNavigation: navigation,
@@ -471,16 +471,24 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
471 fmt.Fprintln(os.Stderr, socketPath)471 fmt.Fprintln(os.Stderr, socketPath)
472 defer func() { _ = endpoint.Close() }()472 defer func() { _ = endpoint.Close() }()
473 }473 }
474+ terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
475+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
476+ if err != nil {
477+ return err
478+ }
474 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)479 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
475 if err != nil {480 if err != nil {
481+ if preconnected != nil {
482+ _ = preconnected.Close()
483+ }
476 return err484 return err
477 }485 }
478 sessionEnv := terminalSessionEnvironment()486 sessionEnv := terminalSessionEnvironment()
479 attachmentEnv := terminalAttachmentEnvironment()487 attachmentEnv := terminalAttachmentEnvironment()
480 attachmentEnv.Cwd = sessionEnv.Cwd488 attachmentEnv.Cwd = sessionEnv.Cwd
481 return runBrokerClient(ctx, brokerClientConfig{489 return runBrokerClient(ctx, brokerClientConfig{
482- Connector: newProductionBrokerConnector(),490+ Connector: withPreconnected(newProductionBrokerConnector(), preconnected),
483- Terminal: observedTerminal{Terminal: physical, UIOutputTransaction: mirror},491+ Terminal: terminal,
484 Clock: clk,492 Clock: clk,
485 UI: ui,493 UI: ui,
486 InitialNavigation: navigation,494 InitialNavigation: navigation,

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