1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/bnema/vev/internal/adapters/clock"
16 "github.com/bnema/vev/internal/adapters/ipc"
17 "github.com/bnema/vev/internal/adapters/term"
18 "github.com/bnema/vev/internal/adapters/uidriver"
19 "github.com/bnema/vev/internal/adapters/uiterm"
20 "github.com/bnema/vev/internal/domain"
21 "github.com/bnema/vev/internal/logging"
22 "github.com/bnema/vev/internal/ports"
23 "github.com/bnema/vev/internal/protocol"
24 "github.com/bnema/vev/internal/usecase/client"
25)
26
27const (
28 uiDriverDefaultColumns = 80
29 uiDriverDefaultRows = 24
30)
31
32// uiDriverOptions is the parsed `--ui-driver` invocation. It carries no launch
33// configuration, no provisioned endpoint, and no daemon ownership: the driver
34// uses the production broker of the process context and never creates,
35// reconfigures, or destroys a daemon or a session-scoped root.
36type uiDriverOptions struct {
37 socket string
38 session string
39 cols int
40 rows int
41 remote string
42 // picker starts with no initial navigation at all: the driver presents the
43 // picker and opens nothing. It is exclusive of --session and --remote.
44 picker bool
45}
46
47type interactiveUIOptions struct {
48 observe bool
49 control bool
50 socket string
51}
52
53func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) {
54 options := interactiveUIOptions{observe: observe, control: control, socket: socket}
55 for len(args) > 0 {
56 switch args[0] {
57 case "--ui-observe":
58 options.observe = true
59 args = args[1:]
60 case "--ui-control":
61 options.control = true
62 args = args[1:]
63 case "--ui-socket":
64 if len(args) < 2 || args[1] == "" {
65 return interactiveUIOptions{}, usagef("`--ui-socket` requires a path")
66 }
67 options.socket = args[1]
68 args = args[2:]
69 default:
70 return interactiveUIOptions{}, usagef("unknown attach option %q", args[0])
71 }
72 }
73 if options.socket != "" && !options.observe && !options.control {
74 return interactiveUIOptions{}, usagef("`--ui-socket` requires `--ui-observe` or `--ui-control`")
75 }
76 if options.socket != "" && (!filepath.IsAbs(options.socket) || strings.IndexByte(options.socket, 0) >= 0) {
77 return interactiveUIOptions{}, usagef("`--ui-socket` requires an absolute path")
78 }
79 return options, nil
80}
81
82// parseUIDriverArgs parses the closed `--ui-driver` option set. `--socket` is
83// the JSONL bridge to an already running client and is exclusive of every
84// headless option; `--picker` is exclusive of `--session` and `--remote`. Any
85// other option, including the removed `--launch-config`, is an unknown option
86// and is refused before any I/O.
87func parseUIDriverArgs(args []string) (uiDriverOptions, error) {
88 options := uiDriverOptions{cols: uiDriverDefaultColumns, rows: uiDriverDefaultRows}
89 var socketSet, sessionSet, colsSet, rowsSet, remoteSet, pickerSet bool
90 for len(args) > 0 {
91 name := args[0]
92 if name == "--help" || name == "-h" {
93 return uiDriverOptions{}, usagef("`--ui-driver` options: --session NAME --cols N --rows N --remote ENDPOINT --picker, or --socket PATH")
94 }
95 if !strings.HasPrefix(name, "--") {
96 return uiDriverOptions{}, usagef("`--ui-driver` does not accept positional arguments")
97 }
98 args = args[1:]
99 value := func(flag string) (string, error) {
100 if len(args) == 0 || args[0] == "" || strings.HasPrefix(args[0], "--") {
101 return "", usagef("`--ui-driver %s` requires a value", flag)
102 }
103 value := args[0]
104 args = args[1:]
105 return value, nil
106 }
107 switch name {
108 case "--socket":
109 if socketSet {
110 return uiDriverOptions{}, usagef("duplicate `--socket`")
111 }
112 value, err := value("--socket")
113 if err != nil {
114 return uiDriverOptions{}, err
115 }
116 if !filepath.IsAbs(value) || strings.IndexByte(value, 0) >= 0 {
117 return uiDriverOptions{}, usagef("`--socket` requires an absolute path")
118 }
119 options.socket, socketSet = value, true
120 case "--session":
121 if sessionSet {
122 return uiDriverOptions{}, usagef("duplicate `--session`")
123 }
124 value, err := value("--session")
125 if err != nil {
126 return uiDriverOptions{}, err
127 }
128 if err := domain.ValidateSessionName(value); err != nil {
129 return uiDriverOptions{}, err
130 }
131 options.session, sessionSet = value, true
132 case "--cols", "--rows":
133 value, err := value(name)
134 if err != nil {
135 return uiDriverOptions{}, err
136 }
137 number, err := strconv.Atoi(value)
138 if err != nil || number <= 0 {
139 return uiDriverOptions{}, usagef("`%s` requires a positive integer", name)
140 }
141 if name == "--cols" {
142 if colsSet {
143 return uiDriverOptions{}, usagef("duplicate `--cols`")
144 }
145 options.cols, colsSet = number, true
146 } else {
147 if rowsSet {
148 return uiDriverOptions{}, usagef("duplicate `--rows`")
149 }
150 options.rows, rowsSet = number, true
151 }
152 case "--remote":
153 if remoteSet {
154 return uiDriverOptions{}, usagef("duplicate `--remote`")
155 }
156 value, err := value("--remote")
157 if err != nil {
158 return uiDriverOptions{}, err
159 }
160 if err := domain.ValidateRemoteHostTarget(value); err != nil {
161 return uiDriverOptions{}, err
162 }
163 options.remote, remoteSet = value, true
164 case "--picker":
165 if pickerSet {
166 return uiDriverOptions{}, usagef("duplicate `--picker`")
167 }
168 options.picker, pickerSet = true, true
169 default:
170 return uiDriverOptions{}, usagef("unknown `--ui-driver` option %q", name)
171 }
172 }
173 if socketSet && (sessionSet || colsSet || rowsSet || remoteSet || pickerSet) {
174 return uiDriverOptions{}, usagef("`--socket` cannot be combined with headless options")
175 }
176 if pickerSet && (sessionSet || remoteSet) {
177 return uiDriverOptions{}, usagef("`--picker` cannot be combined with `--session` or `--remote`")
178 }
179 return options, nil
180}
181
182func runUIDriver(ctx context.Context, options uiDriverOptions) error {
183 if options.socket != "" {
184 return uidriver.Bridge(ctx, options.socket, os.Stdin, os.Stdout)
185 }
186 return runHeadlessUIDriver(ctx, options)
187}
188
189// uiDriverNavigation translates the parsed `--ui-driver` intent into the closed
190// initial-navigation union. Picker is the zero value and opens nothing. Every
191// other intent reuses the shared terminal CLI translation, so the driver cannot
192// invent a destination, an epoch, or a session identity of its own: a local
193// name creates, a bare local invocation creates ephemerally, and a remote
194// target is only ever the registration and lifecycle a broker publication
195// carries.
196func uiDriverNavigation(options uiDriverOptions, requestedAt time.Time) (client.InitialNavigation, client.InitialNavigationResolver, error) {
197 if options.picker {
198 return client.InitialNavigation{}, nil, nil
199 }
200 if options.remote != "" && options.session != "" {
201 // `--remote HOST --session NAME` is attach-or-create: an existing
202 // remote session is attached by its exact lifecycle, an absent one is
203 // created. Absence is only trusted from an observation newer than
204 // this invocation; creating on an older inventory would collide with
205 // a live remote name and be refused.
206 if err := domain.ValidateRemoteHostTarget(options.remote); err != nil {
207 return client.InitialNavigation{}, nil, err
208 }
209 if err := domain.ValidateSessionName(options.session); err != nil {
210 return client.InitialNavigation{}, nil, err
211 }
212 return client.InitialNavigation{}, remoteAttachOrCreateResolver(options.remote, options.session, requestedAt), nil
213 }
214 intent := protocol.IntentEphemeral
215 if options.session != "" {
216 intent = protocol.IntentNew
217 }
218 return terminalBrokerNavigation(intent, options.session, options.remote)
219}
220
221// runHeadlessUIDriver composes one headless UI-driver run: a virtual terminal,
222// the UI service over it, the production broker connector of the process'
223// context, and the one-shot initial navigation the CLI intent names. It never
224// provisions, reconfigures, or destroys an endpoint, and it owns no daemon.
225func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
226 clk := clock.New()
227 navigation, resolver, err := uiDriverNavigation(options, clk.Now())
228 if err != nil {
229 return err
230 }
231 log, logCloser, err := configureLogging(logging.Client, false)
232 if err != nil {
233 return err
234 }
235 defer func() { _ = logCloser.Close() }()
236 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "")
237 if err != nil {
238 return fmt.Errorf("vev: create headless terminal: %w", err)
239 }
240 defer terminal.Close()
241 ui := client.NewUI(terminal, clk)
242 sessionEnv := terminalSessionEnvironment()
243 attachmentEnv := virtualTerminalAttachmentEnvironment()
244 attachmentEnv.Cwd = sessionEnv.Cwd
245 return runUIDriverClient(ctx, brokerClientConfig{
246 Logger: log,
247 Connector: newProductionBrokerConnector(),
248 Terminal: terminal,
249 Clock: clk,
250 UI: ui,
251 InitialNavigation: navigation,
252 ResolveInitialNavigation: resolver,
253 AttachmentEnvironment: attachmentEnv,
254 SessionEnvironment: sessionEnv,
255 }, &stdioStream{reader: os.Stdin, writer: os.Stdout})
256}
257
258// runUIDriverClient is the single JSON Lines UI-driver composition shared by the
259// production `--ui-driver` entry point and the sandbox `_broker-client
260// --harness ui-driver` harness. It launches runBrokerClient exactly once, waits
261// for the run's valid initial UI publication, serves the JSONL protocol over
262// stream, and on EOF or cancellation cancels and joins both workers.
263//
264// It builds no picker, supervisor, dialer, attachment, or navigation of its
265// own: the connector it is given is the only transport authority, so the sandbox
266// injects its own broker IPC connector with no production fallback. A failure of
267// the broker or of a destination is not fatal to the JSONL service: the run
268// stays in the picker with a bounded notice and the driver keeps answering
269// capture and wait. Closing the stream ends only this run: the broker, its
270// daemon, and every session stay alive for the other clients.
271func runUIDriverClient(ctx context.Context, cfg brokerClientConfig, stream io.ReadWriteCloser) error {
272 if cfg.UI == nil || cfg.Terminal == nil || cfg.Clock == nil {
273 return errors.New("vev: UI driver requires a UI, a terminal, and a clock")
274 }
275 if stream == nil {
276 return errors.New("vev: UI driver requires a stream")
277 }
278 runCtx, cancel := context.WithCancel(ctx)
279 defer cancel()
280 // The composition-owned identity and presentation of this run are published
281 // before the broker is reached, so the UI service is usable and describable
282 // even when no broker and no daemon can be reached. It publishes no
283 // actionable generation: an unattached publication reports the honest "no
284 // session owns input" status and leaves the generation to the committed
285 // attachment itself.
286 publishUIDriverInitialContext(cfg.UI)
287 runnerDone := make(chan error, 1)
288 go func() { runnerDone <- runBrokerClient(runCtx, cfg) }()
289
290 snapshot, err := waitForUIDriverPublication(runCtx, cfg.UI)
291 if err != nil {
292 cancel()
293 return errors.Join(err, ignoreContextCancellation(<-runnerDone))
294 }
295 ready := uidriverReady(cfg.UI, snapshot, true)
296 server := uidriver.New(cfg.UI, cfg.Clock)
297 serveDone := make(chan error, 1)
298 go func() { serveDone <- server.Serve(runCtx, stream, ready) }()
299
300 select {
301 case serveErr := <-serveDone:
302 cancel()
303 return errors.Join(serveErr, ignoreContextCancellation(<-runnerDone))
304 case runErr := <-runnerDone:
305 cancel()
306 return errors.Join(runErr, ignoreContextCancellation(<-serveDone))
307 }
308}
309
310// waitForUIDriverPublication waits for the run's first valid UI publication. A
311// valid publication is a committed owned snapshot that carries this run's UI
312// handle and a context that conforms to the presentation table: Picker and
313// Connecting present neither a session identity nor an actionable generation,
314// while Attached presents a real nonzero generation with a committed session
315// boundary. It deliberately requires neither a broker nor a daemon nor a
316// committed view, so a driver whose broker is absent still becomes usable and
317// describable. The startup budget is the existing handshake timeout: a driver
318// that never publishes anything is a real local error, while an unavailable
319// destination is not.
320func waitForUIDriverPublication(ctx context.Context, ui *client.UI) (ports.UISnapshot, error) {
321 readyCtx, cancel := context.WithTimeout(ctx, protocol.HandshakeTimeout)
322 defer cancel()
323 snapshot, err := ui.WaitForSnapshot(readyCtx, func(snapshot ports.UISnapshot) bool {
324 return snapshot.Revision != 0 && snapshot.Context.AttachmentHandle == ui.Handle() && uiDriverContextConforms(snapshot.Context)
325 })
326 if err != nil {
327 if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
328 return ports.UISnapshot{}, fmt.Errorf("vev: UI driver published no initial state: %w", err)
329 }
330 return ports.UISnapshot{}, err
331 }
332 return snapshot, nil
333}
334
335// uiDriverContextConforms reports whether a published context matches the
336// presentation table. An unattached presentation owns no session identity, no
337// output boundary, and no actionable generation; a committed attachment is the
338// only presentation that may carry them, and it must carry a real nonzero
339// generation. A publication that fails this rule is never adopted as the
340// driver's ready state, so a caller can never read a fabricated identity from
341// the discovery response.
342func uiDriverContextConforms(context ports.UIContext) bool {
343 if !context.Status.Valid() {
344 return false
345 }
346 if context.Status == ports.UIStatusAttached {
347 return context.Generation != 0
348 }
349 return context.Generation == 0 && context.Route == (protocol.CommittedRouteIdentity{}) && context.TabID == "" && context.FocusedPaneID == "" &&
350 context.OutputEpoch == 0 && context.OutputState == 0 && context.ViewRevision == 0 && context.ViewPublication == 0
351}
352
353// publishUIDriverInitialContext publishes the composition-owned identity and
354// presentation of one driver run before any broker interaction, so the UI
355// service is usable and describable — capture, wait, and the discovery response
356// — even when no broker and no daemon can be reached.
357//
358// It publishes the Picker presentation, the honest state of a run that owns no
359// session yet. It publishes no actionable generation and no session metadata:
360// the publication carries the run's stable handle and its status only, so a
361// caller can never fence an action against an invented generation.
362func publishUIDriverInitialContext(ui *client.UI) {
363 if ui == nil {
364 return
365 }
366 _ = ui.PublishPresentation(ports.UIStatusPicker)
367}
368
369// uidriverReady projects one published UI state into the driver discovery
370// response. The attachment handle is the run's stable UI service handle, never a
371// session identity; the generation is the real attachment generation and stays
372// zero while no committed attachment has published one.
373func uidriverReady(ui *client.UI, snapshot ports.UISnapshot, control bool) uidriver.Ready {
374 ready := uidriver.Ready{Attachment: ui.Handle(), Control: control, Status: snapshot.Context.Status}
375 if snapshot.Context.Status == ports.UIStatusAttached {
376 ready.Generation = snapshot.Context.Generation
377 }
378 return ready
379}
380
381func ignoreContextCancellation(err error) error {
382 if errors.Is(err, context.Canceled) {
383 return nil
384 }
385 return err
386}
387
388type observedTerminal struct {
389 ports.Terminal
390 ports.UIOutputTransaction
391}
392
393type stdioStream struct {
394 reader io.Reader
395 writer io.Writer
396 once sync.Once
397}
398
399func (s *stdioStream) Read(data []byte) (int, error) { return s.reader.Read(data) }
400func (s *stdioStream) Write(data []byte) (int, error) { return s.writer.Write(data) }
401func (s *stdioStream) Close() error {
402 var closeErr error
403 s.once.Do(func() {
404 for _, value := range []io.Writer{s.writer} {
405 if closer, ok := value.(io.Closer); ok {
406 closeErr = closer.Close()
407 }
408 }
409 if closer, ok := s.reader.(io.Closer); ok {
410 if err := closer.Close(); closeErr == nil {
411 closeErr = err
412 }
413 }
414 })
415 return closeErr
416}
417
418// runAttachWithOptions is the composition path for ordinary interactive
419// clients. Observation is opt-in; the default path remains term.New().
420//
421// The observed path composes the physical terminal, one VT mirror, the UI
422// service over that mirror, and the same broker connector and initial-navigation
423// translation the ordinary terminal attach uses, then delegates the whole run to
424// the shared runBrokerClient. It owns no daemon dialer, no remote carriage, and
425// no session: mirroring observes bytes at the serialized terminal writer and the
426// broker owns every transport.
427func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) {
428 if !options.observe && !options.control && options.socket == "" {
429 return runAttach(ctx, intent, name, remoteTarget)
430 }
431 if options.control {
432 options.observe = true
433 }
434 ctx, stop := clientSignalContext(ctx)
435 defer stop()
436 log, logCloser, err := configureLogging(logging.Client, false)
437 if err != nil {
438 return err
439 }
440 defer func() { _ = logCloser.Close() }()
441 clk := clock.New()
442 // The process trace is still created and joined here so an operator's trace
443 // file keeps its exact lifecycle, including a close failure joined into the
444 // run's result. Client-side transport spans are no longer emitted: the
445 // broker owns carriage, so the observed composition keeps no dialer seam.
446 _, observerCloser, err := newPerformanceTrace(clk)
447 if err != nil {
448 return fmt.Errorf("vev: performance trace: %w", err)
449 }
450 if observerCloser != nil {
451 defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
452 }
453 physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil)
454 geometry, err := physical.Geometry()
455 if err != nil {
456 return fmt.Errorf("vev: reading terminal geometry: %w", err)
457 }
458 mirror, err := uiterm.NewMirror(ctx, geometry, "")
459 if err != nil {
460 return fmt.Errorf("vev: create UI mirror: %w", err)
461 }
462 defer mirror.Close()
463 physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror)
464 ui := client.NewUI(mirror, clk)
465 var endpoint *uidriver.UnixEndpoint
466 if options.observe {
467 server := uidriver.New(ui, clk)
468 socketPath := options.socket
469 if socketPath == "" {
470 socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle())
471 }
472 endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready {
473 ready := uidriver.Ready{Attachment: ui.Handle(), Control: options.control, Status: ports.UIStatusPicker}
474 if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil {
475 ready = uidriverReady(ui, snapshot, options.control)
476 }
477 return ready
478 })
479 if err != nil {
480 return err
481 }
482 fmt.Fprintln(os.Stderr, socketPath)
483 defer func() { _ = endpoint.Close() }()
484 }
485 terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
486 intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
487 if err != nil {
488 return err
489 }
490 navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
491 if err != nil {
492 if preconnected != nil {
493 _ = preconnected.Close()
494 }
495 return err
496 }
497 sessionEnv := terminalSessionEnvironment()
498 attachmentEnv := configuredTerminalAttachmentEnvironment(log)
499 attachmentEnv.Cwd = sessionEnv.Cwd
500 return runBrokerClient(ctx, brokerClientConfig{
501 Logger: log,
502 Connector: withPreconnected(newProductionBrokerConnector(), preconnected),
503 Terminal: terminal,
504 Clock: clk,
505 UI: ui,
506 InitialNavigation: navigation,
507 ResolveInitialNavigation: resolver,
508 AttachmentEnvironment: attachmentEnv,
509 SessionEnvironment: sessionEnv,
510 })
511}