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