1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bnema/vev/internal/adapters/clipboard"
14 "github.com/bnema/vev/internal/adapters/clock"
15 "github.com/bnema/vev/internal/adapters/term"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/terminalcap"
18 "github.com/bnema/vev/internal/platform"
19 "github.com/bnema/vev/internal/ports"
20 "github.com/bnema/vev/internal/protocol"
21 "github.com/bnema/vev/internal/protocol/catalogue"
22 "github.com/bnema/vev/internal/usecase/client"
23)
24
25// Common broker client composition.
26//
27// runBrokerClient is the single terminal composition every frontend promotes:
28// the sandbox terminal harness, the sandbox UI-driver harness, and the ordinary
29// production terminal attach all build one brokerClientConfig and delegate
30// here. It constructs exactly one client.Picker and exactly one
31// client.Supervisor and hands the whole run to Supervisor.Run, so no frontend
32// keeps a parallel picker, supervisor, attachment stream, or daemon dialer.
33//
34// The composition owns only what the supervisor cannot: the connector (or the
35// production connect-or-spawn behind it), the terminal, the clock, the optional
36// UI publication/action seam, the one-shot initial navigation intent (or the
37// single-shot resolver that translates a CLI target into it), the copied
38// attachment environment, the optional lifecycle-action channel, and the
39// presentation callbacks. It never opens a stream, resolves a key, or drives
40// picker input itself.
41
42// brokerClientConfig is the common client configuration shared by every
43// frontend. Connector, Terminal, and Clock are required (a nil Clock falls back
44// to the process clock); the remaining fields are optional and keep the
45// supervisor defaults when they are left zero.
46type brokerClientConfig struct {
47 Logger *slog.Logger
48 Connector ports.BrokerConnector
49 Terminal ports.Terminal
50 Clock ports.Clock
51 Clipboard ports.ClipboardReader
52 UI *client.UI
53 // InitialNavigation is the one-shot intent decided before the connection.
54 // Its zero value is InitialNavigationPicker, which opens nothing.
55 InitialNavigation client.InitialNavigation
56 // ResolveInitialNavigation translates a composition-owned CLI target (an
57 // attach name or a remote endpoint) into an exact navigation identity
58 // against the first committed broker publication. It is mutually exclusive
59 // with a non-zero InitialNavigation.
60 ResolveInitialNavigation client.InitialNavigationResolver
61 // AttachmentEnvironment is the copied process context of each attachment
62 // Hello. It is never read from the broker process.
63 AttachmentEnvironment client.AttachmentEnvironment
64 // SessionEnvironment is the process snapshot for session creation. The
65 // production frontends capture one LocalCLI snapshot before NewSupervisor;
66 // offline and test compositions pass an explicit valid value (LocalCLI
67 // with empty Env/Cwd unless the test asserts env). A zero value falls
68 // back to the production LocalCLI snapshot inside runBrokerClient so the
69 // ordinary terminal path keeps no second capture.
70 SessionEnvironment client.SessionEnvironment
71 // LifecycleActions carries explicit attachment lifecycle requests. A nil
72 // channel disables external lifecycle actions.
73 LifecycleActions <-chan client.AttachmentLifecycleAction
74 // OnState observes every supervisor state. Optional.
75 OnState func(client.State)
76 // OnFailure surfaces one typed attachment or connectivity failure without
77 // ending the run. Optional.
78 OnFailure func(error)
79 // OnLifecycle reports typed, bounded lifecycle transitions. Optional.
80 OnLifecycle func(client.LifecycleNotice)
81}
82
83// brokerClientPresentation is the single terminal writer of one broker client
84// run. Rendering the picker and reporting a bounded failure both go through it,
85// so a failure notice can never interleave bytes with a picker frame.
86type brokerClientPresentation struct {
87 terminal ports.Terminal
88 picker *client.Picker
89 // ui is the run's UI owner. When set, the picker frame is written inside the
90 // terminal's UI output transaction under the Picker presentation, exactly like
91 // daemon output, so the committed snapshot and the painted bytes describe the
92 // same unattached state. Optional: a test may paint without an observation
93 // channel.
94 ui *client.UI
95 onState func(client.State)
96 clock ports.Clock
97 spinner ports.Timer
98 frame int
99 // spinning is set while a transition notice is due or shown, so the
100 // spinner timer exists only then and never repaints the plain picker.
101 spinning bool
102 // connectingSince is when the current Connecting presentation began, zero
103 // outside it. The notice stays hidden for connectingNoticeDelay so a fast
104 // session switch never flashes it.
105 connectingSince time.Time
106
107 mu sync.Mutex
108}
109
110// connectingNoticeDelay is how long Connecting stays silent before its notice
111// is painted. A spinner tick repaints after it elapses.
112const connectingNoticeDelay = 200 * time.Millisecond
113
114// Render observes the state and paints the picker frame and notice while the
115// shared client.PickerPresentation fence admits the presentation. That is
116// exactly PresentPicker: an admitted attachment foreground owns the same
117// terminal writer before its attached marker and initial publication, and a
118// terminating process is leaving the terminal, so neither is ever painted over.
119// Picker is therefore never painted while the supervisor presents Connecting.
120//
121// The frame is committed through the terminal's UI output transaction under the
122// Picker presentation, so the published context and the painted bytes are one
123// coherent unattached state; Picker carries the run's handle with no session
124// identity and no actionable generation. An unavailable observation channel
125// leaves the frame written and flushed, exactly like the attachment foreground.
126//
127// ResizeEvents has exactly one consumer for the whole supervisor run (its
128// attachment geometry collector); rendering samples Geometry here at render
129// time instead of competing for that single event channel, so a repaint always
130// observes the latest size.
131func (p *brokerClientPresentation) Render(state client.State) {
132 if p == nil {
133 return
134 }
135 if p.onState != nil {
136 p.onState(state)
137 }
138 p.mu.Lock()
139 defer p.mu.Unlock()
140 if state.Presentation != client.PresentConnecting {
141 p.connectingSince = time.Time{}
142 }
143 if !client.PickerPresentation(state) && (state.Presentation != client.PresentConnecting || p.clock == nil) {
144 return
145 }
146 if state.Presentation == client.PresentConnecting || state.Connectivity == client.ConnectivityRetryWait {
147 p.spinning = true
148 }
149 if state.Presentation == client.PresentConnecting && state.Connectivity != client.ConnectivityRetryWait && !state.SlowConnecting {
150 now := p.clock.Now()
151 if p.connectingSince.IsZero() {
152 p.connectingSince = now
153 }
154 if now.Sub(p.connectingSince) < connectingNoticeDelay {
155 return
156 }
157 }
158 geometry, err := p.terminal.Geometry()
159 if err != nil {
160 return
161 }
162 var frame, notice []byte
163 if client.PickerPresentation(state) {
164 frame = p.picker.Render(geometry.Size)
165 notice = p.picker.RenderNotice(geometry.Size)
166 } else {
167 frame = client.RenderTransitionNotice(geometry.Size, p.frame, "Connecting to session…")
168 }
169 if state.Connectivity == client.ConnectivityRetryWait {
170 notice = client.RenderTransitionNotice(geometry.Size, p.frame, "Connection lost; retrying…")
171 }
172 if len(frame) == 0 && len(notice) == 0 {
173 return
174 }
175 writer := p.terminal.Out()
176 if writer == nil {
177 return
178 }
179 transaction, _ := p.terminal.(ports.UIOutputTransaction)
180 context := p.pickerContext()
181 switch state.Presentation {
182 case client.PresentConnecting:
183 context.Status = ports.UIStatusConnecting
184 case client.PresentAttachedPicker:
185 // The picker is composed over a live attachment: the attachment stays
186 // the actionable generation, so the frame keeps its attached context.
187 if attached, ok := p.ui.OverlayContext(); ok {
188 context = attached
189 }
190 }
191 if transaction != nil {
192 transaction.BeginOutput(context)
193 if err := transaction.PublishContext(context); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
194 transaction.EndOutput(false)
195 return
196 }
197 }
198 success := true
199 if len(frame) != 0 {
200 if _, writeErr := writer.Write(frame); writeErr != nil {
201 success = false
202 }
203 }
204 if success && len(notice) != 0 {
205 if _, writeErr := writer.Write(notice); writeErr != nil {
206 success = false
207 }
208 }
209 if success && p.terminal.Flush() != nil {
210 success = false
211 }
212 if transaction != nil {
213 transaction.EndOutput(success)
214 }
215}
216
217func (p *brokerClientPresentation) Spinner() <-chan time.Time {
218 p.mu.Lock()
219 defer p.mu.Unlock()
220 if !p.spinning || p.clock == nil {
221 return nil
222 }
223 if p.spinner == nil {
224 delay := 120 * time.Millisecond
225 if !p.connectingSince.IsZero() {
226 // While the notice is held back, the next tick lands exactly when
227 // it becomes due.
228 if remaining := connectingNoticeDelay - p.clock.Now().Sub(p.connectingSince); remaining > 0 {
229 delay = remaining
230 }
231 }
232 p.spinner = p.clock.NewTimer(delay)
233 }
234 return p.spinner.C()
235}
236
237func (p *brokerClientPresentation) AdvanceSpinner(state client.State) {
238 p.mu.Lock()
239 p.frame++
240 if p.spinner != nil {
241 p.spinner.Reset(120 * time.Millisecond)
242 }
243 p.mu.Unlock()
244 p.Render(state)
245}
246
247func (p *brokerClientPresentation) StopSpinner() {
248 p.mu.Lock()
249 p.spinning = false
250 if p.spinner != nil {
251 p.spinner.Stop()
252 // A stopped timer never fires again: the next transition needs a
253 // fresh one or its delayed notice would never appear.
254 p.spinner = nil
255 }
256 p.mu.Unlock()
257}
258
259// pickerContext is the Picker presentation of this run: the stable UI handle and
260// the status alone. It is the shape every unattached publication uses, so a
261// capture of the painted picker never shows an old attachment's session
262// identity or generation.
263func (p *brokerClientPresentation) pickerContext() ports.UIContext {
264 context := ports.UIContext{Status: ports.UIStatusPicker}
265 if p.ui != nil {
266 context.AttachmentHandle = p.ui.Handle()
267 }
268 return context
269}
270
271// Failure reports one bounded attachment or connectivity failure on the same
272// terminal writer. The supervisor already reports a typed, bounded lifecycle
273// notice through the picker, so this is the complementary diagnostic: one
274// bounded line under the shared writer lock, never a raw cause chain and never
275// a competing terminal owner.
276func (p *brokerClientPresentation) Failure(state client.State, err error) {
277 if p == nil || err == nil {
278 return
279 }
280 // A retryable failure is already shown by the repainted retry notice. A
281 // raw line here would scroll the painted screen on every attempt and
282 // leave a trail of stale notices behind it.
283 if state.Connectivity == client.ConnectivityRetryWait {
284 return
285 }
286 text := err.Error()
287 if len(text) > ports.BrokerMaxErrorBytes {
288 text = text[:ports.BrokerMaxErrorBytes]
289 }
290 p.mu.Lock()
291 defer p.mu.Unlock()
292 writer := p.terminal.Out()
293 if writer == nil {
294 return
295 }
296 if _, writeErr := writer.Write([]byte("\r\nvev: " + text + "\r\n")); writeErr != nil {
297 return
298 }
299 _ = p.terminal.Flush()
300}
301
302// runBrokerClient builds exactly one picker and one supervisor over cfg and
303// delegates the whole run to the supervisor. It returns the supervisor's
304// terminal cause: a clean terminal EOF, an explicit exit, or the run's
305// cancellation, joined with any failure the supervisor classified as terminal.
306func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
307 if cfg.Terminal == nil {
308 return errors.New("broker client requires a terminal")
309 }
310 clk := cfg.Clock
311 if clk == nil {
312 clk = clock.New()
313 }
314 sessionEnv := cfg.SessionEnvironment
315 if sessionEnv.Provenance == client.SessionEnvironmentUnspecified {
316 // Production fallback: capture one LocalCLI process snapshot before
317 // NewSupervisor so ordinary terminal attach keeps no second capture.
318 sessionEnv = terminalSessionEnvironment()
319 }
320 attachmentEnv := cfg.AttachmentEnvironment
321 if sessionEnv.Cwd != "" {
322 attachmentEnv.Cwd = sessionEnv.Cwd
323 }
324 reader := cfg.Clipboard
325 if reader == nil {
326 reader = clipboard.New()
327 }
328 picker := client.NewPicker(clk, 0, attachmentEnv.Color)
329 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
330 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
331 Logger: cfg.Logger,
332 Connector: cfg.Connector,
333 Terminal: cfg.Terminal,
334 Clock: clk,
335 Clipboard: reader,
336 UI: cfg.UI,
337 Picker: picker,
338 InitialNavigation: cfg.InitialNavigation,
339 ResolveInitialNavigation: cfg.ResolveInitialNavigation,
340 AttachmentEnvironment: attachmentEnv,
341 SessionEnvironment: sessionEnv,
342 LifecycleActions: cfg.LifecycleActions,
343 Render: presentation.Render,
344 Notify: func(state client.State, err error) {
345 if cfg.OnFailure != nil {
346 cfg.OnFailure(err)
347 return
348 }
349 presentation.Failure(state, err)
350 },
351 NotifyLifecycle: func(notice client.LifecycleNotice) {
352 if cfg.OnLifecycle != nil {
353 cfg.OnLifecycle(notice)
354 }
355 if notice.Kind == client.LifecycleNoticeBrokerLost {
356 picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeWarn, Message: "Connection lost; retrying…"})
357 }
358 if notice.Kind == client.LifecycleNoticeBrokerReconnected {
359 picker.Notify(domain.Notification{Code: domain.NoticeConnection, Severity: domain.NoticeInfo, Message: "Connection restored"})
360 }
361 },
362 Spinner: presentation,
363 })
364 if err != nil {
365 return err
366 }
367 return supervisor.Run(ctx)
368}
369
370// localEphemeralNavigation is the no-argument product intent: create one
371// ephemeral session on the broker-owned local daemon. It is decided before the
372// connection, so it carries no epoch and needs no resolver.
373func localEphemeralNavigation() client.InitialNavigation {
374 return client.InitialNavigation{
375 Kind: client.InitialNavigationCreateEphemeral,
376 Destination: ports.BrokerEndpointFence{Local: true},
377 }
378}
379
380// errTerminalNavigationUnresolved reports that a CLI target names a host the
381// broker catalogue does not carry. It is a local refusal:
382// no destination was dialed, so the supervisor surfaces it as a bounded
383// selection-unavailable notice and returns to the picker instead of attaching a
384// fallback or creating implicitly.
385var errTerminalNavigationUnresolved = errors.New("attach host is not in the broker catalogue")
386
387// terminalBrokerNavigation translates the parsed terminal CLI intent into the
388// closed initial-navigation union. An intent that is fully determined before
389// the connection (local creation, local named attach) is a value; a remote
390// target is a single-shot resolver that runs once against the first committed
391// snapshot to fence the host registration.
392//
393// Named attach never consults the catalogue for session existence: the daemon
394// restores or refuses the name. A remote destination is only ever the complete
395// registration a snapshot carries; an unknown host is a refusal.
396func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {
397 if remoteTarget == "" {
398 switch intent {
399 case protocol.IntentEphemeral:
400 return localEphemeralNavigation(), nil, nil
401 case protocol.IntentNew:
402 if err := domain.ValidateSessionName(name); err != nil {
403 return client.InitialNavigation{}, nil, err
404 }
405 return client.InitialNavigation{
406 Kind: client.InitialNavigationCreateNamed,
407 Destination: ports.BrokerEndpointFence{Local: true},
408 Name: name,
409 }, nil, nil
410 case protocol.IntentAttach:
411 if err := domain.ValidateSessionName(name); err != nil {
412 return client.InitialNavigation{}, nil, err
413 }
414 // The daemon resolves the name, so a catalogue that has not
415 // observed the session yet cannot refuse it.
416 return client.InitialNavigation{
417 Kind: client.InitialNavigationAttachNamed,
418 Destination: ports.BrokerEndpointFence{Local: true},
419 Name: name,
420 }, nil, nil
421 default:
422 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
423 }
424 }
425 if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
426 return client.InitialNavigation{}, nil, err
427 }
428 switch intent {
429 case protocol.IntentEphemeral:
430 // `attach user@host` without a session keeps its product behavior: a
431 // fresh ephemeral session on that remote daemon.
432 return client.InitialNavigation{}, remoteEphemeralCreateResolver(remoteTarget), nil
433 case protocol.IntentNew:
434 if err := domain.ValidateSessionName(name); err != nil {
435 return client.InitialNavigation{}, nil, err
436 }
437 return client.InitialNavigation{}, remoteNamedCreateResolver(remoteTarget, name), nil
438 case protocol.IntentAttach:
439 if err := domain.ValidateSessionName(name); err != nil {
440 return client.InitialNavigation{}, nil, err
441 }
442 return client.InitialNavigation{}, remoteNamedAttachResolver(remoteTarget, name), nil
443 default:
444 return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
445 }
446}
447
448// remoteNamedAttachResolver binds a remote named attach to the endpoint's
449// committed registration; the remote daemon resolves the name.
450func remoteNamedAttachResolver(endpoint, name string) client.InitialNavigationResolver {
451 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
452 observation, ok := snapshot.Find(endpoint)
453 if !ok {
454 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
455 }
456 return client.InitialNavigation{
457 Kind: client.InitialNavigationAttachNamed,
458 Epoch: snapshot.Epoch,
459 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
460 Name: name,
461 }, nil
462 }
463}
464
465// remoteExactAttachResolver resolves one remote session name to its exact
466// lifecycle under the endpoint's committed registration. The destination is the
467// complete observed registration (endpoint, incarnation, and generation), never
468// a bare hostname and never an invented daemon identity; an endpoint the broker
469// does not carry is refused rather than dialed.
470func remoteExactAttachResolver(endpoint, name string) client.InitialNavigationResolver {
471 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
472 observation, ok := snapshot.Find(endpoint)
473 if !ok {
474 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
475 }
476 target, ok := brokerObservationExactTarget(observation, name)
477 if !ok {
478 return client.InitialNavigation{}, fmt.Errorf("%w: %q on %q", errTerminalNavigationUnresolved, name, endpoint)
479 }
480 return client.InitialNavigation{
481 Kind: client.InitialNavigationAttachExact,
482 Epoch: snapshot.Epoch,
483 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
484 Target: target,
485 }, nil
486 }
487}
488
489func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationResolver {
490 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
491 observation, ok := snapshot.Find(endpoint)
492 if !ok {
493 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
494 }
495 return client.InitialNavigation{
496 Kind: client.InitialNavigationCreateNamed,
497 Epoch: snapshot.Epoch,
498 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
499 Name: name,
500 }, nil
501 }
502}
503
504// remoteAttachOrCreateResolver resolves one remote session name to an exact
505// attach when the endpoint's committed observation carries it, and to a named
506// creation fenced to the same registration once an inventory observed at or
507// after requestedAt proves it absent. Before such an observation it asks the
508// supervisor to request one and resolve again, with the creation as the
509// fallback should the host stay unobserved. An endpoint the broker does not
510// carry is refused rather than dialed.
511func remoteAttachOrCreateResolver(endpoint, name string, requestedAt time.Time) client.InitialNavigationResolver {
512 attach := remoteExactAttachResolver(endpoint, name)
513 create := remoteNamedCreateResolver(endpoint, name)
514 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
515 observation, ok := snapshot.Find(endpoint)
516 if !ok {
517 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
518 }
519 if _, exists := brokerObservationExactTarget(observation, name); exists {
520 return attach(snapshot)
521 }
522 if !observation.InventoryKnown || observation.LastSuccess.Before(requestedAt) {
523 // Should the host stay unobserved, creating is still the only
524 // resolvable intent; the remote daemon refuses a live name.
525 fallback, err := create(snapshot)
526 if err != nil {
527 fallback = client.InitialNavigation{}
528 }
529 return client.InitialNavigation{}, client.InitialNavigationNotObserved{Endpoint: endpoint, Fallback: fallback}
530 }
531 return create(snapshot)
532 }
533}
534
535// remoteEphemeralCreateResolver resolves `attach user@host` (no session) into a
536// remote ephemeral creation fenced to the endpoint's committed registration and
537// the epoch of the snapshot that resolved it.
538func remoteEphemeralCreateResolver(endpoint string) client.InitialNavigationResolver {
539 return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
540 observation, ok := snapshot.Find(endpoint)
541 if !ok {
542 return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
543 }
544 return client.InitialNavigation{
545 Kind: client.InitialNavigationCreateEphemeral,
546 Epoch: snapshot.Epoch,
547 Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
548 }, nil
549 }
550}
551
552// localBrokerObservation returns the single local daemon observation of one
553// publication.
554func localBrokerObservation(snapshot ports.BrokerSnapshot) (ports.BrokerDaemonObservation, bool) {
555 for _, observation := range snapshot.Daemons {
556 if observation.Local {
557 return observation, true
558 }
559 }
560 return ports.BrokerDaemonObservation{}, false
561}
562
563// brokerObservationExactTarget resolves one session name to the exact
564// lifecycle/name pair the observation publishes. A broken session is refused:
565// its identity is known but not attachable, so an explicit attempt would only be
566// a guaranteed refusal.
567func brokerObservationExactTarget(observation ports.BrokerDaemonObservation, name string) (protocol.ExactSessionTarget, bool) {
568 for _, session := range observation.Sessions {
569 if session.Name != name || session.State == catalogue.RemoteCatalogSessionBroken {
570 continue
571 }
572 target := protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
573 if err := target.Validate(); err != nil {
574 return protocol.ExactSessionTarget{}, false
575 }
576 return target, true
577 }
578 return protocol.ExactSessionTarget{}, false
579}
580
581// productionBrokerConnector is every frontend's production broker connector. It
582// performs no I/O at construction: Connect connect-or-spawns the per-user
583// broker on demand, so a broker that is absent or incompatible is an ordinary
584// attempt failure under the supervisor's own backoff instead of a fatal startup
585// error. It never dials a daemon and never selects a transport.
586//
587// It is stateless beyond its connect function: the supervisor owns and closes
588// every service it adopts, so the connector keeps no eager connection and no
589// close path of its own.
590type productionBrokerConnector struct {
591 connect func(context.Context) (ports.BrokerService, error)
592}
593
594func localBrokerOperationRoute(snapshot ports.BrokerSnapshot) client.BrokerOperationRoute {
595 return client.BrokerOperationRoute{
596 Epoch: snapshot.Epoch,
597 Destination: ports.BrokerEndpointFence{Local: true},
598 }
599}
600
601var _ ports.BrokerConnector = (*productionBrokerConnector)(nil)
602
603// newProductionBrokerConnector returns a lazy production connector. The
604// connect function is a composition seam for tests; production uses the shared
605// connect-or-spawn.
606func newProductionBrokerConnector() ports.BrokerConnector {
607 return &productionBrokerConnector{connect: func(ctx context.Context) (ports.BrokerService, error) {
608 return connectProductionClientBroker(ctx)
609 }}
610}
611
612// Connect connect-or-spawns one broker connection for the calling attempt,
613// including the first one, so every attempt is under the supervisor's retry
614// cadence.
615func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
616 if c == nil || c.connect == nil {
617 return nil, errors.New("broker connector is not configured")
618 }
619 service, err := c.connect(ctx)
620 if err != nil {
621 return nil, err
622 }
623 return service, nil
624}
625
626// terminalBrokerSeams are the optional observation callbacks of one terminal
627// run. Production supplies none: runBrokerClient reports a failure on the
628// terminal itself through the same writer the picker paints with. The terminal
629// composition tests substitute every callback to observe the same run's states,
630// lifecycle notices, and failures.
631type terminalBrokerSeams struct {
632 OnState func(client.State)
633 OnLifecycle func(client.LifecycleNotice)
634 OnFailure func(error)
635}
636
637// terminalBrokerCallbacks is the composition-root seam producing one terminal
638// run's callbacks.
639var terminalBrokerCallbacks = func() terminalBrokerSeams { return terminalBrokerSeams{} }
640
641// Composition-root seams for the terminal client. Production supplies the
642// production connect-or-spawn, the physical terminal, and the detached local
643// creation; tests substitute them so the terminal composition is exercised
644// without a production broker process, a real controlling terminal, or a real
645// daemon.
646var (
647 connectProductionClientBroker = connectProductionBroker
648 terminalForAttach = func() ports.Terminal { return term.New() }
649 createDetachedTerminalSession = createDetachedLocalSession
650)
651
652// terminalAttachmentEnvironment copies the composition-owned process context an
653// attachment Hello carries. The supervisor and the broker never inspect the
654// process environment themselves.
655func terminalAttachmentEnvironment() client.AttachmentEnvironment {
656 return client.AttachmentEnvironment{
657 TermEnv: os.Getenv("TERM"),
658 Cwd: currentWorkingDirectory(),
659 Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
660 PID: uint32(os.Getpid()),
661 }
662}
663
664// detectTerminalColor derives the color capability from the process
665// environment. Explicit TERM/COLORTERM values override env entries.
666func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
667 detectionEnv := make([]string, 0, len(env)+2)
668 detectionEnv = append(detectionEnv, env...)
669 detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
670 return terminalcap.Detect(detectionEnv).Color
671}
672
673// envColors is the client-side color-mode override. It wins over the
674// terminal.colors config key and is read by each client process; the daemon
675// never sees it except as the Forced Hello.Color it produces.
676const envColors = "VEV_COLORS"
677
678// virtualTerminalColor is the color capability declared by clients whose
679// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
680// headless UI driver) rather than the gateway process' controlling terminal.
681// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
682var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
683
684// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
685// client that renders into an RGB-capable virtual terminal: it declares forced
686// truecolor instead of detecting from the gateway process environment.
687func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
688 env := terminalAttachmentEnvironment()
689 env.Color = virtualTerminalColor
690 return env
691}
692
693// resolveColorOverride applies the explicit color override on top of the
694// detected capability: a valid VEV_COLORS wins over terminal.colors, and
695// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
696// config value still applies. An invalid VEV_COLORS is reported through log
697// and ignored. A forced result carries SourceForced.
698func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
699 if strings.TrimSpace(envValue) != "" {
700 mode, auto, err := terminalcap.ParseColorMode(envValue)
701 switch {
702 case err != nil:
703 if log != nil {
704 log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
705 }
706 case auto:
707 return detected
708 default:
709 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
710 }
711 }
712 if cfg.ColorsSet {
713 return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
714 }
715 return detected
716}
717
718// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
719// client whose terminal is the user's real one: the detected color capability
720// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
721// both the picker and every Hello.
722func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
723 env := terminalAttachmentEnvironment()
724 env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
725 return env
726}
727
728// configuredTerminalAttachmentEnvironment loads the user's config and returns
729// userTerminalAttachmentEnvironment for it, with the user's echo setting.
730func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
731 cfg := loadConfigOrDefaults(log, platform.ConfigPath())
732 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
733 env.EchoPredict = cfg.Echo.Predict
734 return env
735}
736
737// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
738// for a client that owns a real outer terminal: it probes that terminal once
739// and applies the user's keyboard setting. Virtual terminals never probe.
740func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
741 cfg := loadConfigOrDefaults(log, platform.ConfigPath())
742 env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
743 env.ProbeTerminal = true
744 env.KittyKeyboard = cfg.Keyboard.KittyProtocol
745 env.EchoPredict = cfg.Echo.Predict
746 return env
747}
748
749// terminalSessionEnvironment captures one LocalCLI process snapshot for
750// session creation before NewSupervisor. The supervisor remaps each remote
751// request to Remote, so the base provenance stays LocalCLI without branching
752// in the app.
753func terminalSessionEnvironment() client.SessionEnvironment {
754 return client.SessionEnvironment{
755 Provenance: client.SessionEnvironmentLocalCLI,
756 Env: append([]string(nil), os.Environ()...),
757 Cwd: currentWorkingDirectory(),
758 }
759}
760
761func currentWorkingDirectory() string {
762 cwd, err := os.Getwd()
763 if err != nil {
764 return ""
765 }
766 return cwd
767}