detached 5ac8017

refactor(app): split run.go, single CLI error prefix, group daemon test seams (#310)

* refactor(app): split run.go by concern and parse subcommands separately

Move CLI parsing to cli.go, the daemon composition to daemon_run.go, the
attach paths to attach.go, session list/kill to sessions.go, and runtime
tracing to trace.go. parseArgs now delegates to per-subcommand parsers
without goto, and the three UI flags become one interactiveUIOptions field.

* refactor(errors): add the vev: prefix only in the CLI entry point

Internal errors no longer start with "vev: "; cliError in main.go adds
the single user-facing prefix, so the prefix-stripping loop is removed.
The interactive missing-session prompt and direct stderr notices keep
their prefix because they bypass cliError.

Visible effect: TUI toasts, uidriver JSONL errors, and broker error text
no longer carry "vev:", and the client failure line no longer prints a
doubled "vev: vev:".

* refactor(daemon): group test seams into an embedded daemonTestHooks struct

The 37 before/after hooks move out of the Daemon field list into one
embedded struct, so production state reads separately from test seams.
Embedding keeps every d.beforeX call site unchanged.

* refactor(app): place broker seams with their callers and widen source guards

Move the broker connection seams, list helpers, and errKillOutcomeUnknown
next to their callers in sessions.go and run.go, and the graceful teardown
waiter to broker_offline_mux.go. trace.go keeps only tracing. Drop the
inner vev: prefix from the unreadable-catalogue error, extend the terminal
composition source guard to sessions.go and run.go, and add parser cases
for leading UI flags and kill --.

* test(daemonmux): tolerate a broker reset racing the refused-open settlement

The refusing acceptor sent Refused and then settled its local stream. The
broker can answer the refusal with a Reset that terminalizes the stream
first, so the later local Refused returned ErrStreamState and failed
TestLogicalOpenRefused on loaded CI runners. A stream already terminal
at that point is settled and no longer counts as a failure.
commit
5ac801757a752bbb8fe31d95d287b810cd4a7d2f
author
bnema · · 2026-10-04 16:20 UTC
committer
GitHub · · 2026-10-04 16:20 UTC
parent
6f8be80
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
52 files · +1573 -1478
M boundary_test.go +1 -1
@@ -369,7 +369,7 @@ func inspectExternalDependencies(t *testing.T) []string {
369369 // carriage, so the composition is pinned to the multi-policy call, and the
370370 // shorthand stays a fixtures-only convenience.
371371 func TestProductionDaemonBindingComposition(t *testing.T) {
372- const runFile = "internal/app/run.go"
372+ const runFile = "internal/app/daemon_run.go"
373373
374374 var (
375375 bindingSites []string
M internal/adapters/daemonmux/logical_conn_test.go +11 -1
@@ -260,7 +260,17 @@ func (a *daemonAcceptor) startRefused(id PhysicalStreamID, deadline time.Time) {
260260 a.fail(err)
261261 return
262262 }
263- if err := a.pump.Engine().Refused(Refused{Ref: status.Ref, Error: refusal}); err != nil {
263+ // The scheduler only sends for an admitted stream, so the local
264+ // settlement follows the send. The broker may react to the refusal
265+ // with a Reset that terminalizes the stream first; that is a settled
266+ // stream, not a failure.
267+ err = a.pump.Engine().Refused(Refused{Ref: status.Ref, Error: refusal})
268+ if errors.Is(err, ErrStreamState) {
269+ if current, ok := a.pump.Engine().Status(id); ok && current.State == StreamTerminal {
270+ err = nil
271+ }
272+ }
273+ if err != nil {
264274 a.fail(err)
265275 return
266276 }
A internal/app/attach.go +162 -0
@@ -0,0 +1,162 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+
9+ "github.com/bnema/vev/internal/adapters/clock"
10+ "github.com/bnema/vev/internal/adapters/ipc"
11+ "github.com/bnema/vev/internal/adapters/term"
12+ "github.com/bnema/vev/internal/adapters/uidriver"
13+ "github.com/bnema/vev/internal/adapters/uiterm"
14+ "github.com/bnema/vev/internal/logging"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// runAttach dials (auto-spawning the daemon if needed) and runs the client
21+// attach loop. Logging goes to the shared file: the client must never write
22+// to the console while the terminal is raw.
23+func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
24+ if os.Getenv("VEV") != "" {
25+ if remoteTarget == "" && intent == protocol.IntentNew {
26+ return createDetachedTerminalSession(ctx, name)
27+ }
28+ return errors.New("sessions should be nested with care; unset VEV to force")
29+ }
30+ ctx, stop := clientSignalContext(ctx)
31+ defer stop()
32+ log, logCloser, err := configureLogging(logging.Client, false)
33+ if err != nil {
34+ return err
35+ }
36+ defer func() { _ = logCloser.Close() }()
37+ clk := clock.New()
38+ observer, observerCloser, err := newPerformanceTrace(clk)
39+ if err != nil {
40+ return fmt.Errorf("performance trace: %w", err)
41+ }
42+ if observerCloser != nil {
43+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
44+ }
45+ if observer != nil {
46+ observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true))
47+ }
48+ terminal := terminalForAttach()
49+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
50+ if err != nil {
51+ return err
52+ }
53+ navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
54+ if err != nil {
55+ if preconnected != nil {
56+ _ = preconnected.Close()
57+ }
58+ return err
59+ }
60+ callbacks := terminalBrokerCallbacks()
61+ return runBrokerClient(ctx, brokerClientConfig{
62+ Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
63+ InitialNavigation: navigation, ResolveInitialNavigation: resolver,
64+ AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(),
65+ OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
66+ })
67+}
68+
69+// runAttachWithOptions is the composition path for ordinary interactive
70+// clients. Observation is opt-in; the default path remains term.New().
71+//
72+// The observed path composes the physical terminal, one VT mirror, the UI
73+// service over that mirror, and the same broker connector and initial-navigation
74+// translation the ordinary terminal attach uses, then delegates the whole run to
75+// the shared runBrokerClient. It owns no daemon dialer, no remote carriage, and
76+// no session: mirroring observes bytes at the serialized terminal writer and the
77+// broker owns every transport.
78+func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget string, options interactiveUIOptions) (retErr error) {
79+ if !options.enabled() {
80+ return runAttach(ctx, intent, name, remoteTarget)
81+ }
82+ if options.control {
83+ options.observe = true
84+ }
85+ ctx, stop := clientSignalContext(ctx)
86+ defer stop()
87+ log, logCloser, err := configureLogging(logging.Client, false)
88+ if err != nil {
89+ return err
90+ }
91+ defer func() { _ = logCloser.Close() }()
92+ clk := clock.New()
93+ // The process trace is still created and joined here so an operator's trace
94+ // file keeps its exact lifecycle, including a close failure joined into the
95+ // run's result. Client-side transport spans are no longer emitted: the
96+ // broker owns carriage, so the observed composition keeps no dialer seam.
97+ _, observerCloser, err := newPerformanceTrace(clk)
98+ if err != nil {
99+ return fmt.Errorf("performance trace: %w", err)
100+ }
101+ if observerCloser != nil {
102+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
103+ }
104+ physical := term.NewWithFilesAndObservation(os.Stdin, os.Stdout, nil)
105+ geometry, err := physical.Geometry()
106+ if err != nil {
107+ return fmt.Errorf("reading terminal geometry: %w", err)
108+ }
109+ mirror, err := uiterm.NewMirror(ctx, geometry, "")
110+ if err != nil {
111+ return fmt.Errorf("create UI mirror: %w", err)
112+ }
113+ defer mirror.Close()
114+ physical = term.NewWithFilesAndObservation(os.Stdin, os.Stdout, mirror)
115+ ui := client.NewUI(mirror, clk)
116+ var endpoint *uidriver.UnixEndpoint
117+ if options.observe {
118+ server := uidriver.New(ui, clk)
119+ socketPath := options.socket
120+ if socketPath == "" {
121+ socketPath = uidriver.DefaultSocketPath(ipc.SocketDir(), ui.Handle())
122+ }
123+ endpoint, err = uidriver.ListenUnix(socketPath, server, func() uidriver.Ready {
124+ ready := uidriver.Ready{Attachment: ui.Handle(), Control: options.control, Status: ports.UIStatusPicker}
125+ if snapshot, snapshotErr := ui.Capture(ui.Handle()); snapshotErr == nil {
126+ ready = uidriverReady(ui, snapshot, options.control)
127+ }
128+ return ready
129+ })
130+ if err != nil {
131+ return err
132+ }
133+ fmt.Fprintln(os.Stderr, socketPath)
134+ defer func() { _ = endpoint.Close() }()
135+ }
136+ terminal := observedTerminal{Terminal: physical, UIOutputTransaction: mirror}
137+ intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
138+ if err != nil {
139+ return err
140+ }
141+ navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
142+ if err != nil {
143+ if preconnected != nil {
144+ _ = preconnected.Close()
145+ }
146+ return err
147+ }
148+ sessionEnv := terminalSessionEnvironment()
149+ attachmentEnv := configuredTerminalAttachmentEnvironment(log)
150+ attachmentEnv.Cwd = sessionEnv.Cwd
151+ return runBrokerClient(ctx, brokerClientConfig{
152+ Logger: log,
153+ Connector: withPreconnected(newProductionBrokerConnector(), preconnected),
154+ Terminal: terminal,
155+ Clock: clk,
156+ UI: ui,
157+ InitialNavigation: navigation,
158+ ResolveInitialNavigation: resolver,
159+ AttachmentEnvironment: attachmentEnv,
160+ SessionEnvironment: sessionEnv,
161+ })
162+}
M internal/app/broker_client.go +3 -3
@@ -264,7 +264,7 @@ func (p *brokerClientPresentation) Failure(state client.State, err error) {
264264 // cancellation, joined with any failure the supervisor classified as terminal.
265265 func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
266266 if cfg.Terminal == nil {
267- return errors.New("vev: broker client requires a terminal")
267+ return errors.New("broker client requires a terminal")
268268 }
269269 clk := cfg.Clock
270270 if clk == nil {
@@ -341,7 +341,7 @@ func localEphemeralNavigation() client.InitialNavigation {
341341 // no destination was dialed, so the supervisor surfaces it as a bounded
342342 // selection-unavailable notice and returns to the picker instead of attaching a
343343 // fallback or creating implicitly.
344-var errTerminalNavigationUnresolved = errors.New("vev: attach host is not in the broker catalogue")
344+var errTerminalNavigationUnresolved = errors.New("attach host is not in the broker catalogue")
345345
346346 // terminalBrokerNavigation translates the parsed terminal CLI intent into the
347347 // closed initial-navigation union. An intent that is fully determined before
@@ -573,7 +573,7 @@ func newProductionBrokerConnector() ports.BrokerConnector {
573573 // cadence.
574574 func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerNavigator, error) {
575575 if c == nil || c.connect == nil {
576- return nil, errors.New("vev: broker connector is not configured")
576+ return nil, errors.New("broker connector is not configured")
577577 }
578578 service, err := c.connect(ctx)
579579 if err != nil {
M internal/app/broker_client_test.go +2 -1
@@ -1012,9 +1012,10 @@ func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) {
10121012 // outright, so this fails if any is reintroduced as a bypass.
10131013 func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) {
10141014 sources := loadAppSources(t, false)
1015- body := sources["run.go"]
1015+ body := sources["attach.go"]
10161016 require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
10171017 require.Contains(t, body, "newProductionBrokerConnector()")
1018+ body += sources["sessions.go"] + sources["run.go"]
10181019 for _, forbidden := range []string{
10191020 "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer",
10201021 "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry",
M internal/app/broker_daemon_start.go +6 -6
@@ -144,7 +144,7 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
144144 }
145145 starter := localDaemonStarter
146146 if starter.dial == nil || starter.spawn == nil {
147- return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon starter is incomplete")}
147+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon starter is incomplete")}
148148 }
149149 raw, err := starter.dial(ctx, carriage)
150150 if err == nil {
@@ -172,12 +172,12 @@ func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports
172172 // or empty path can never trigger a spawn.
173173 func validateDaemonCarriage(carriage string) error {
174174 if carriage == "" {
175- return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon carriage path is empty")}
175+ return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("daemon carriage path is empty")}
176176 }
177177 if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage {
178178 return ports.BrokerError{
179179 Code: ports.BrokerErrorIncompatible,
180- Cause: fmt.Errorf("vev: daemon carriage %q is not an absolute cleaned path", carriage),
180+ Cause: fmt.Errorf("daemon carriage %q is not an absolute cleaned path", carriage),
181181 }
182182 }
183183 return nil
@@ -191,7 +191,7 @@ func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) e
191191 if want := starter.effectivePublished(); carriage != want {
192192 return ports.BrokerError{
193193 Code: ports.BrokerErrorIncompatible,
194- Cause: fmt.Errorf("vev: refusing to start a daemon for carriage %q: this process publishes %q", carriage, want),
194+ Cause: fmt.Errorf("refusing to start a daemon for carriage %q: this process publishes %q", carriage, want),
195195 }
196196 }
197197 return nil
@@ -231,7 +231,7 @@ func brokerDaemonStartArgvFlag(mode ports.BrokerDaemonStartMode) (string, error)
231231 case ports.BrokerDaemonStartIfNeeded:
232232 return "if-needed", nil
233233 default:
234- return "", fmt.Errorf("vev: invalid broker daemon start mode %s", mode)
234+ return "", fmt.Errorf("invalid broker daemon start mode %s", mode)
235235 }
236236 }
237237
@@ -245,6 +245,6 @@ func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode,
245245 case "if-needed":
246246 return ports.BrokerDaemonStartIfNeeded, nil
247247 default:
248- return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value)
248+ return 0, fmt.Errorf("unknown daemon start mode %q (want existing-only or if-needed)", value)
249249 }
250250 }
M internal/app/broker_offline.go +16 -16
@@ -96,14 +96,14 @@ func (b productionIdentityBinder) BindAuthenticatedIdentity(ctx context.Context,
9696 return b.delegate.BindAuthenticatedIdentity(ctx, request)
9797 }
9898 if request.Policy != b.policy {
99- return "", errors.New("vev: local daemon policy authority changed")
99+ return "", errors.New("local daemon policy authority changed")
100100 }
101101 identity, err := daemonidentity.Load(b.stateDir)
102102 if err != nil {
103- return "", fmt.Errorf("vev: revalidate daemon authority: %w", err)
103+ return "", fmt.Errorf("revalidate daemon authority: %w", err)
104104 }
105105 if identity != request.Identity {
106- return "", errors.New("vev: local daemon identity authority changed")
106+ return "", errors.New("local daemon identity authority changed")
107107 }
108108 return identity, nil
109109 }
@@ -139,7 +139,7 @@ func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (ret
139139 defer stop()
140140 observer, observerCloser, err := newPerformanceTrace(clock.New())
141141 if err != nil {
142- return fmt.Errorf("vev: performance trace: %w", err)
142+ return fmt.Errorf("performance trace: %w", err)
143143 }
144144 if observer != nil {
145145 observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true))
@@ -165,7 +165,7 @@ func sandboxLogging(dir string) (*slog.Logger, io.Closer, error) {
165165 func newBrokerEpoch() (ports.BrokerEpoch, error) {
166166 var raw [8]byte
167167 if _, err := rand.Read(raw[:]); err != nil {
168- return 0, fmt.Errorf("vev: sample broker epoch: %w", err)
168+ return 0, fmt.Errorf("sample broker epoch: %w", err)
169169 }
170170 epoch := ports.BrokerEpoch(binary.BigEndian.Uint64(raw[:]))
171171 if epoch == 0 {
@@ -211,26 +211,26 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
211211 return err
212212 }
213213 if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
214- return fmt.Errorf("vev: secure broker runtime parent: %w", err)
214+ return fmt.Errorf("secure broker runtime parent: %w", err)
215215 }
216216 layout := productionBrokerLayout()
217217 configPath := productionBrokerConfigPath()
218218 for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} {
219219 if err := safedir.EnsurePrivate(dir); err != nil {
220- return fmt.Errorf("vev: secure broker sandbox directory %s: %w", dir, err)
220+ return fmt.Errorf("secure broker sandbox directory %s: %w", dir, err)
221221 }
222222 }
223223 // Re-walk every component now that EnsurePrivate created them, so a
224224 // component swapped for a symlink in that window fails closed.
225225 if err := layout.VerifyCreated(); err != nil {
226- return fmt.Errorf("vev: verify broker paths: %w", err)
226+ return fmt.Errorf("verify broker paths: %w", err)
227227 }
228228 // The broker is allowed to precede the daemon. Identity is therefore an
229229 // optional existing authority here; acquisition binds it after an
230230 // authenticated StartIfNeeded connection when the daemon is first born.
231231 identity, err := daemonidentity.Load(platform.StateDir())
232232 if err != nil && !errors.Is(err, os.ErrNotExist) {
233- return fmt.Errorf("vev: load daemon authority: %w", err)
233+ return fmt.Errorf("load daemon authority: %w", err)
234234 }
235235 config, err := brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir()))
236236 if err != nil {
@@ -242,7 +242,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
242242 }
243243 owner, err := deps.acquire(layout.Runtime)
244244 if err != nil {
245- return fmt.Errorf("vev: broker sandbox ownership: %w", err)
245+ return fmt.Errorf("broker sandbox ownership: %w", err)
246246 }
247247 defer func() { retErr = errors.Join(retErr, owner.Release()) }()
248248
@@ -258,7 +258,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
258258 for _, endpoint := range config.Endpoints() {
259259 registration, _ := config.Registration(endpoint)
260260 if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 {
261- return errors.New("vev: broker routes require SSH trust and timeouts in OpenSSH configuration")
261+ return errors.New("broker routes require SSH trust and timeouts in OpenSSH configuration")
262262 }
263263 }
264264 initialHosts, err := configuredBrokerHosts(config)
@@ -267,7 +267,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
267267 }
268268 store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State, InitialHosts: initialHosts, InitialImportProvided: true})
269269 if err != nil {
270- return fmt.Errorf("vev: open broker sandbox store: %w", err)
270+ return fmt.Errorf("open broker sandbox store: %w", err)
271271 }
272272 defer func() { retErr = errors.Join(retErr, store.Close()) }()
273273 if err := upgradeBrokerHostVersions(store); err != nil {
@@ -356,7 +356,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
356356 socketPath := brokeripc.SocketPath(layout.Runtime)
357357 listener, err := deps.listen(socketPath, epoch, authority, brokeripc.Config{OnRetire: cancel})
358358 if err != nil {
359- return fmt.Errorf("vev: listen on broker sandbox socket: %w", err)
359+ return fmt.Errorf("listen on broker sandbox socket: %w", err)
360360 }
361361 if err := supervisor.RegisterCloseable("listener", listener); err != nil {
362362 _ = listener.Close()
@@ -410,11 +410,11 @@ func configuredBrokerHosts(config *brokerconfig.Config) ([]ports.BrokerHostRecor
410410 for _, endpoint := range config.Endpoints() {
411411 registration, ok := config.Registration(endpoint)
412412 if !ok {
413- return nil, fmt.Errorf("vev: missing configured broker registration %q", endpoint)
413+ return nil, fmt.Errorf("missing configured broker registration %q", endpoint)
414414 }
415415 route, err := durableBrokerRoute(registration.Route)
416416 if err != nil {
417- return nil, fmt.Errorf("vev: import configured broker host %q: %w", endpoint, err)
417+ return nil, fmt.Errorf("import configured broker host %q: %w", endpoint, err)
418418 }
419419 records = append(records, ports.BrokerHostRecord{Registration: registration.Registration, Pinned: true, Policy: registration.Policy, Identity: registration.Identity, Route: route})
420420 }
@@ -456,7 +456,7 @@ func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error)
456456 // with, exactly as before.
457457 func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (broker.RegistryConfig, error) {
458458 if config == nil {
459- return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")
459+ return broker.RegistryConfig{}, errors.New("offline registry requires configuration")
460460 }
461461 if _, ok := config.LocalBinding(); !ok {
462462 mode := broker.MembershipImmutable
M internal/app/broker_offline_client_test_helpers_test.go +1 -1
@@ -20,7 +20,7 @@ import (
2020 // or stream of its own.
2121 func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error {
2222 if terminal == nil {
23- return errors.New("vev: offline client requires a terminal")
23+ return errors.New("offline client requires a terminal")
2424 }
2525 if log != nil {
2626 log.Debug("broker_offline_client", "socket", socket)
M internal/app/broker_offline_local.go +4 -4
@@ -30,10 +30,10 @@ func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPoli
3030 // time; this re-checks the exported binding a caller may have built itself,
3131 // because the dial seam routes an ssh address to a child process.
3232 if !route.IsLocal() {
33- return nil, errors.New("vev: broker local probe requires a local route")
33+ return nil, errors.New("broker local probe requires a local route")
3434 }
3535 if err := route.Validate(); err != nil {
36- return nil, fmt.Errorf("vev: broker local probe: %w", err)
36+ return nil, fmt.Errorf("broker local probe: %w", err)
3737 }
3838 return broker.NewLocalDaemonProbe(epoch, route.Address(), policy, connector, sessionwire.BrokerCodec{}, loadIdentity)
3939 }
@@ -43,7 +43,7 @@ func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPoli
4343 func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalDaemonProbe, error) {
4444 binding, ok := config.LocalBinding()
4545 if !ok {
46- return nil, errors.New("vev: broker local probe requires a provisioned local binding")
46+ return nil, errors.New("broker local probe requires a provisioned local binding")
4747 }
4848 loader := broker.LocalIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
4949 if len(loaders) != 0 {
@@ -58,7 +58,7 @@ func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, conn
5858 func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...broker.LocalIdentityLoader) (*broker.LocalObservation, error) {
5959 binding, ok := config.LocalBinding()
6060 if !ok {
61- return nil, errors.New("vev: broker local observation requires a provisioned local binding")
61+ return nil, errors.New("broker local observation requires a provisioned local binding")
6262 }
6363 probe, err := brokerLocalProbe(config, epoch, connector, loaders...)
6464 if err != nil {
M internal/app/broker_offline_mux.go +33 -9
@@ -181,7 +181,7 @@ func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *broker.Route
181181 }
182182 binding, ok := config.LocalBinding()
183183 if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) {
184- return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")}
184+ return nil, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("broker mux: local route authority does not match the resolved target")}
185185 }
186186 return dialBrokerRoute(ctx, binding.Route, target, log)
187187 }, daemonmux.DefaultMuxCeilings())
@@ -221,7 +221,7 @@ func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, target ports
221221 case brokerconfig.RouteSSHQUIC:
222222 return dialBrokerQUICRoute(ctx, route, target.StartMode, log)
223223 default:
224- return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind())
224+ return nil, fmt.Errorf("broker mux: unsupported route kind %q", route.Kind())
225225 }
226226 }
227227
@@ -241,7 +241,7 @@ func sshMuxCommandSpec(route brokerconfig.Route, mode ports.BrokerDaemonStartMod
241241 argv := route.Argv()
242242 for _, word := range argv {
243243 if word == brokerDaemonStartArg {
244- return sshstdio.CommandSpec{}, fmt.Errorf("vev: broker mux route provisions its own %s", brokerDaemonStartArg)
244+ return sshstdio.CommandSpec{}, fmt.Errorf("broker mux route provisions its own %s", brokerDaemonStartArg)
245245 }
246246 }
247247 argv = append(argv, brokerDaemonStartArg, flag)
@@ -452,7 +452,7 @@ func brokerQUICUnavailable(action string, err error) error {
452452 if err == nil {
453453 err = errors.New("unavailable")
454454 }
455- return fmt.Errorf("vev: broker mux QUIC unavailable: %s: %w", action, err)
455+ return fmt.Errorf("broker mux QUIC unavailable: %s: %w", action, err)
456456 }
457457
458458 // runBrokerMuxStdioCommand bridges the process' own stdio to the single
@@ -464,12 +464,12 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err
464464 if daemonCarriageAbsence(err) {
465465 return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
466466 }
467- return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)
467+ return fmt.Errorf("broker mux stdio: dial daemonmux carriage: %w", err)
468468 }
469469 bounded, ok := raw.(wire.BoundedTransport)
470470 if !ok {
471471 _ = raw.Close()
472- return errors.New("vev: broker mux stdio: carriage is not a bounded transport")
472+ return errors.New("broker mux stdio: carriage is not a bounded transport")
473473 }
474474 return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), bounded)
475475 }
@@ -522,7 +522,7 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti
522522 if err != nil {
523523 _ = cmd.Process.Kill()
524524 _ = cmd.Wait()
525- return fmt.Errorf("vev: broker mux bootstrap readiness: %w", err)
525+ return fmt.Errorf("broker mux bootstrap readiness: %w", err)
526526 }
527527 line, err := quic.EncodeReadiness(readiness)
528528 if err != nil {
@@ -576,12 +576,12 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions)
576576 if daemonCarriageAbsence(err) {
577577 return &exitCoded{code: sshstdio.MuxExitNoDaemon, err: err}
578578 }
579- return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)
579+ return fmt.Errorf("broker mux quic proxy: dial daemonmux carriage: %w", err)
580580 }
581581 bounded, ok := raw.(wire.BoundedTransport)
582582 if !ok {
583583 _ = raw.Close()
584- return errors.New("vev: broker mux quic proxy: carriage is not a bounded transport")
584+ return errors.New("broker mux quic proxy: carriage is not a bounded transport")
585585 }
586586 return bridgeMuxTransports(ctx, transport, bounded)
587587 }
@@ -643,3 +643,27 @@ func orderlyRelay(err error) error {
643643 }
644644 return err
645645 }
646+
647+// gracefulTeardownWaiter is implemented by carriages (QUIC) whose Close defers
648+// a bounded connection close past its return. The broker mux QUIC proxy owns its
649+// process, so it must wait for that close before returning: exiting first
650+// would discard the final synchronous envelope Close already handed to the
651+// stream.
652+type gracefulTeardownWaiter interface {
653+ WaitGracefulTeardown(ctx context.Context) error
654+}
655+
656+// proxyTeardownTimeout bounds the proxy's wait for a deferred graceful close,
657+// leaving headroom beyond the adapter's own graceful window. A carriage that
658+// does not defer its close is not waited on.
659+const proxyTeardownTimeout = 2 * time.Second
660+
661+func waitGracefulTeardown(transport wire.Transport) error {
662+ waiter, ok := transport.(gracefulTeardownWaiter)
663+ if !ok {
664+ return nil
665+ }
666+ ctx, cancel := context.WithTimeout(context.Background(), proxyTeardownTimeout)
667+ defer cancel()
668+ return waiter.WaitGracefulTeardown(ctx)
669+}
M internal/app/broker_offline_status.go +20 -20
@@ -72,15 +72,15 @@ var (
7272 // errBrokerAbsent reports that no broker is bound at the endpoint, so a
7373 // spawn may recover it. A missing socket and a stale socket left by a dead
7474 // owner are both absence.
75- errBrokerAbsent = errors.New("vev: broker endpoint is absent")
75+ errBrokerAbsent = errors.New("broker endpoint is absent")
7676 // errBrokerIncompatible reports that the endpoint exists but is not a
7777 // compatible broker (a foreign path, a live endpoint that fails the broker
7878 // handshake, or a handshake that stalls past its bound). It is never an
7979 // invitation to spawn.
80- errBrokerIncompatible = errors.New("vev: broker endpoint is not a compatible broker")
80+ errBrokerIncompatible = errors.New("broker endpoint is not a compatible broker")
8181 // errBrokerNotReady reports that --ensure did not observe a ready broker
8282 // before the overall deadline.
83- errBrokerNotReady = errors.New("vev: broker did not become ready")
83+ errBrokerNotReady = errors.New("broker did not become ready")
8484 )
8585
8686 // brokerStatusReport is the bounded JSON status document. It carries exactly
@@ -160,11 +160,11 @@ func runProductionBrokerLauncherCommand(context.Context) error {
160160 func startDetachedBrokerServe(args []string, crashPath string) error {
161161 exePath, err := selfExePath()
162162 if err != nil {
163- return fmt.Errorf("vev: resolving executable path: %w", err)
163+ return fmt.Errorf("resolving executable path: %w", err)
164164 }
165165 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
166166 if err != nil {
167- return fmt.Errorf("vev: opening %s: %w", os.DevNull, err)
167+ return fmt.Errorf("opening %s: %w", os.DevNull, err)
168168 }
169169 defer func() { _ = devNull.Close() }()
170170
@@ -177,21 +177,21 @@ func startDetachedBrokerServe(args []string, crashPath string) error {
177177 cmd.Stderr = devNull
178178 } else {
179179 if err := safedir.EnsurePrivate(filepath.Dir(crashPath)); err != nil {
180- return fmt.Errorf("vev: secure broker crash log directory: %w", err)
180+ return fmt.Errorf("secure broker crash log directory: %w", err)
181181 }
182182 if err := os.Rename(crashPath, crashPath+".prev"); err != nil && !errors.Is(err, os.ErrNotExist) {
183- return fmt.Errorf("vev: preserve previous broker crash log: %w", err)
183+ return fmt.Errorf("preserve previous broker crash log: %w", err)
184184 }
185185 crashLog, err := os.OpenFile(crashPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
186186 if err != nil {
187- return fmt.Errorf("vev: open broker crash log: %w", err)
187+ return fmt.Errorf("open broker crash log: %w", err)
188188 }
189189 defer func() { _ = crashLog.Close() }()
190190 cmd.Stderr = crashLog
191191 }
192192 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
193193 if err := cmd.Start(); err != nil {
194- return fmt.Errorf("vev: starting detached broker: %w", err)
194+ return fmt.Errorf("starting detached broker: %w", err)
195195 }
196196 return cmd.Process.Release()
197197 }
@@ -209,11 +209,11 @@ func spawnProductionBrokerLauncher(ctx context.Context) error {
209209 func runBrokerLauncherProcess(ctx context.Context, args []string) error {
210210 exePath, err := selfExePath()
211211 if err != nil {
212- return fmt.Errorf("vev: resolving executable path: %w", err)
212+ return fmt.Errorf("resolving executable path: %w", err)
213213 }
214214 devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
215215 if err != nil {
216- return fmt.Errorf("vev: opening %s: %w", os.DevNull, err)
216+ return fmt.Errorf("opening %s: %w", os.DevNull, err)
217217 }
218218 defer func() { _ = devNull.Close() }()
219219
@@ -226,9 +226,9 @@ func runBrokerLauncherProcess(ctx context.Context, args []string) error {
226226 cmd.Stderr = &stderr
227227 if err := cmd.Run(); err != nil {
228228 if detail := bytes.TrimSpace(stderr.Bytes()); len(detail) > 0 {
229- return fmt.Errorf("vev: launching broker: %w: %s", err, detail)
229+ return fmt.Errorf("launching broker: %w: %s", err, detail)
230230 }
231- return fmt.Errorf("vev: launching broker: %w", err)
231+ return fmt.Errorf("launching broker: %w", err)
232232 }
233233 return nil
234234 }
@@ -255,10 +255,10 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
255255 // directory lives beneath the runtime directory, and both must be private
256256 // and symlink-free before they can hold an exclusive lock.
257257 if err := safedir.EnsurePrivate(req.layout.Spawn); err != nil {
258- return brokerStatusReport{}, fmt.Errorf("vev: secure broker sandbox spawn directory: %w", err)
258+ return brokerStatusReport{}, fmt.Errorf("secure broker sandbox spawn directory: %w", err)
259259 }
260260 if err := req.layout.VerifyCreated(); err != nil {
261- return brokerStatusReport{}, fmt.Errorf("vev: verify broker sandbox paths: %w", err)
261+ return brokerStatusReport{}, fmt.Errorf("verify broker sandbox paths: %w", err)
262262 }
263263
264264 var held brokerStatusLock
@@ -293,7 +293,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
293293 return brokerStatusReport{}, err
294294 }
295295 if !errors.Is(err, errBrokerAbsent) {
296- return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err)
296+ return brokerStatusReport{}, fmt.Errorf("broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err)
297297 }
298298
299299 if held == nil {
@@ -314,7 +314,7 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
314314 return brokerStatusReport{}, err
315315 }
316316 if !errors.Is(err, errBrokerAbsent) {
317- return brokerStatusReport{}, fmt.Errorf("vev: broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err)
317+ return brokerStatusReport{}, fmt.Errorf("broker endpoint %s is live but incompatible: %w; run \"vev kill --all\" and retry", req.socketPath, err)
318318 }
319319 case errors.Is(err, lifecycle.ErrBusy):
320320 // Another caller is elected and spawning. Only the elected
@@ -329,13 +329,13 @@ func ensureBrokerReady(ctx context.Context, req brokerStatusRequest, deps broker
329329 }
330330 continue
331331 default:
332- return brokerStatusReport{}, fmt.Errorf("vev: elect broker spawner: %w", err)
332+ return brokerStatusReport{}, fmt.Errorf("elect broker spawner: %w", err)
333333 }
334334 }
335335 if held != nil && !spawned {
336336 busy, err := deps.lifetimeOwner(req.layout.Runtime)
337337 if err != nil {
338- return brokerStatusReport{}, fmt.Errorf("vev: inspect broker lifetime owner: %w", err)
338+ return brokerStatusReport{}, fmt.Errorf("inspect broker lifetime owner: %w", err)
339339 }
340340 if !busy {
341341 if err := deps.spawn(ctx, req.root, req.grace, req.graceSet); err != nil {
@@ -379,7 +379,7 @@ func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusRepo
379379 case errors.Is(err, fs.ErrNotExist):
380380 return brokerStatusReport{}, absentError(socketPath)
381381 case err != nil:
382- return brokerStatusReport{}, fmt.Errorf("vev: inspect broker endpoint %s: %w", socketPath, err)
382+ return brokerStatusReport{}, fmt.Errorf("inspect broker endpoint %s: %w", socketPath, err)
383383 case info.Mode()&os.ModeSocket == 0:
384384 return brokerStatusReport{}, incompatibleError(socketPath, errors.New("path is not a Unix socket"))
385385 }
M internal/app/broker_production.go +7 -7
@@ -30,14 +30,14 @@ func productionBrokerConfigPath() string {
3030 func ensureProductionBrokerConfig() error {
3131 path := productionBrokerConfigPath()
3232 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
33- return fmt.Errorf("vev: secure broker configuration directory: %w", err)
33+ return fmt.Errorf("secure broker configuration directory: %w", err)
3434 }
3535 file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
3636 if errors.Is(err, os.ErrExist) {
3737 return nil
3838 }
3939 if err != nil {
40- return fmt.Errorf("vev: create broker configuration: %w", err)
40+ return fmt.Errorf("create broker configuration: %w", err)
4141 }
4242 _, writeErr := file.WriteString(`{"marker":"vev.broker.offline/v1","registrations":[]}`)
4343 closeErr := file.Close()
@@ -46,9 +46,9 @@ func ensureProductionBrokerConfig() error {
4646 }
4747 removeErr := os.Remove(path)
4848 if writeErr != nil {
49- return errors.Join(fmt.Errorf("vev: write broker configuration: %w", writeErr), closeErr, removeErr)
49+ return errors.Join(fmt.Errorf("write broker configuration: %w", writeErr), closeErr, removeErr)
5050 }
51- return errors.Join(fmt.Errorf("vev: close broker configuration: %w", closeErr), removeErr)
51+ return errors.Join(fmt.Errorf("close broker configuration: %w", closeErr), removeErr)
5252 }
5353
5454 // remoteBrokerPolicy builds the remote host policy for a CLI transport name
@@ -57,7 +57,7 @@ func ensureProductionBrokerConfig() error {
5757 func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
5858 route, ok := hostTransportRoutes[transport]
5959 if !ok {
60- panic(fmt.Sprintf("vev: unknown host transport %q", transport))
60+ panic(fmt.Sprintf("unknown host transport %q", transport))
6161 }
6262 return ports.BrokerPolicy{
6363 ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
@@ -73,7 +73,7 @@ func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
7373 func upgradeBrokerHostVersions(store ports.BrokerHostStore) error {
7474 hosts, err := store.LoadHosts()
7575 if err != nil {
76- return fmt.Errorf("vev: load broker hosts: %w", err)
76+ return fmt.Errorf("load broker hosts: %w", err)
7777 }
7878 changed := false
7979 records := ports.CloneBrokerHostRecords(hosts.Hosts)
@@ -95,7 +95,7 @@ func upgradeBrokerHostVersions(store ports.BrokerHostStore) error {
9595 return nil
9696 }
9797 if err := store.ReplaceHosts(hosts.Revision, records); err != nil {
98- return fmt.Errorf("vev: upgrade broker host versions: %w", err)
98+ return fmt.Errorf("upgrade broker host versions: %w", err)
9999 }
100100 return nil
101101 }
M internal/app/broker_production_connect.go +6 -6
@@ -43,7 +43,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
4343 // Validate the shared parent before creating broker-owned descendants,
4444 // especially when a long XDG root maps directly into /tmp.
4545 if err := safedir.EnsurePrivate(ipc.SocketDir()); err != nil {
46- return nil, fmt.Errorf("vev: secure broker runtime parent: %w", err)
46+ return nil, fmt.Errorf("secure broker runtime parent: %w", err)
4747 }
4848 layout := productionBrokerLayout()
4949 request := brokerStatusRequest{
@@ -57,7 +57,7 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
5757 }
5858 connect := func() (ports.BrokerService, error) {
5959 if _, err := ensureBrokerReady(ctx, request, deps); err != nil {
60- return nil, fmt.Errorf("vev: ensure broker: %w", err)
60+ return nil, fmt.Errorf("ensure broker: %w", err)
6161 }
6262 return brokeripc.NewConnector(request.socketPath, brokeripc.Config{}).Connect(ctx)
6363 }
@@ -65,23 +65,23 @@ func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
6565 if errors.Is(err, brokeripc.ErrBrokerRetired) {
6666 owner, acquireErr := lifecycle.Acquire(ctx, layout.Runtime, 50*time.Millisecond)
6767 if acquireErr != nil {
68- return nil, fmt.Errorf("vev: wait for retiring broker: %w", acquireErr)
68+ return nil, fmt.Errorf("wait for retiring broker: %w", acquireErr)
6969 }
7070 if releaseErr := owner.Release(); releaseErr != nil {
71- return nil, fmt.Errorf("vev: release broker ownership: %w", releaseErr)
71+ return nil, fmt.Errorf("release broker ownership: %w", releaseErr)
7272 }
7373 request.deadline = time.Now().Add(productionBrokerStartupTimeout)
7474 service, err = connect()
7575 }
7676 if err != nil {
77- return nil, fmt.Errorf("vev: connect broker: %w", err)
77+ return nil, fmt.Errorf("connect broker: %w", err)
7878 }
7979 if _, stale := brokeripc.StaleBuild(service); stale {
8080 fmt.Fprintln(os.Stderr, `vev: the running broker is from another build; run "vev kill --all" to restart it`)
8181 }
8282 if err := awaitBrokerPublication(ctx, service); err != nil {
8383 _ = service.Close()
84- return nil, fmt.Errorf("vev: await broker publication: %w", err)
84+ return nil, fmt.Errorf("await broker publication: %w", err)
8585 }
8686 return service, nil
8787 }
A internal/app/cli.go +394 -0
@@ -0,0 +1,394 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// cmdKind identifies which sub-command the CLI parsed.
12+type cmdKind int
13+
14+const (
15+ kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
16+ kindList
17+ kindHost
18+ kindKill
19+ kindCmd
20+ kindEnv
21+ kindDaemon
22+ kindDaemonLauncher
23+ kindUIDriver
24+ kindUIRemoteCleanup
25+ kindDaemonStop
26+ kindWebDaemon
27+ kindWebRenew
28+ kindWebServe
29+ kindBrokerMuxStdio
30+ kindBrokerMuxQUICBootstrap
31+ kindBrokerMuxQUICProxy
32+ kindProductionBrokerServe
33+ kindProductionBrokerLauncher
34+ kindBrokerReady
35+ kindHelp
36+ kindVersion
37+)
38+
39+// command is the parsed CLI invocation: what to do, plus the attach intent
40+// and session name where relevant.
41+type command struct {
42+ kind cmdKind
43+ intent uint8
44+ name string
45+ remoteTarget string
46+ listHost string
47+ listAll bool
48+ hostAction string
49+ hostTarget string
50+ // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH.
51+ hostTransport string
52+ killAll bool
53+ killSessions bool
54+ cmd cmdInvocation
55+ env envInvocation
56+ brokerServe brokerServeOptions
57+ brokerMux brokerMuxOptions
58+ brokerReady brokerReadyOptions
59+ uiDriver uiDriverOptions
60+ ui interactiveUIOptions
61+ web webOptions
62+}
63+
64+// usageError is a user-facing argument error; the app prints it (with usage)
65+// rather than a stack of wrapped internals.
66+type usageError struct{ msg string }
67+
68+func (e *usageError) Error() string { return e.msg }
69+
70+func usagef(format string, args ...any) error {
71+ return &usageError{msg: fmt.Sprintf(format, args...)}
72+}
73+
74+const usageText = `vev — a terminal multiplexer
75+
76+usage:
77+ vev attach to (or create) an ephemeral session
78+ vev new <name> create and attach to a named session
79+ vev attach <name> attach to an existing session (alias: a)
80+ vev attach user@host[:session]
81+ attach through SSH to a remote vev daemon
82+ vev ls list local sessions
83+ vev ls <host> list sessions on a known remote host
84+ vev ls --all list local and remote sessions
85+ vev host add [--transport quic|ssh] <host>
86+ add a pinned remote host (default transport: quic)
87+ vev host rm <host> remove a pinned remote host
88+ vev host list list known remote hosts
89+ vev kill <name> kill a session
90+ vev kill --sessions kill all sessions (vev keeps running)
91+ vev kill --all stop everything: every vev window, the broker, and the
92+ daemon (named sessions come back on the next start)
93+ vev cmd <command> run a control command (vev cmd --help)
94+ vev env <fish|sh> print shell code that refreshes desktop variables
95+ vev --ui-observe expose passive observation for this interactive client
96+ (optional: --ui-socket PATH)
97+ vev --ui-control expose observation and input control for this client
98+ (optional: --ui-socket PATH)
99+ vev --web-daemon start the private web terminal or print its current link
100+ vev --web-renew-token revoke web access and print a fresh link
101+ Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT]
102+ vev --help show this help
103+ vev --version show version`
104+
105+// parseArgs turns the raw argv tail into a command. It is deliberately a
106+// pure function so the full dispatch table can be unit-tested without any
107+// I/O.
108+func parseArgs(args []string) (command, error) {
109+ if len(args) > 0 && args[0] == "--ui-driver" {
110+ options, err := parseUIDriverArgs(args[1:])
111+ if err != nil {
112+ return command{}, err
113+ }
114+ return command{kind: kindUIDriver, uiDriver: options}, nil
115+ }
116+ ui, args, err := parseLeadingUIFlags(args)
117+ if err != nil {
118+ return command{}, err
119+ }
120+ if len(args) == 0 {
121+ return command{kind: kindAttach, intent: protocol.IntentEphemeral, ui: ui}, nil
122+ }
123+ switch args[0] {
124+ case "new":
125+ return parseNewArgs(args[1:], ui)
126+ case "attach", "a":
127+ return parseAttachArgs(args[1:], ui)
128+ }
129+ if ui.enabled() {
130+ return command{}, usagef("UI flags are only valid for an attach command")
131+ }
132+ return parseSubcommand(args)
133+}
134+
135+// parseLeadingUIFlags consumes the interactive UI flags placed before the
136+// subcommand and returns the remaining arguments.
137+func parseLeadingUIFlags(args []string) (interactiveUIOptions, []string, error) {
138+ end := 0
139+ for end < len(args) {
140+ switch args[end] {
141+ case "--ui-observe", "--ui-control":
142+ end++
143+ case "--ui-socket":
144+ if end+1 >= len(args) || args[end+1] == "" {
145+ return interactiveUIOptions{}, nil, usagef("`--ui-socket` requires a path")
146+ }
147+ end += 2
148+ default:
149+ ui, err := parseInteractiveUIFlags(args[:end], interactiveUIOptions{})
150+ return ui, args[end:], err
151+ }
152+ }
153+ ui, err := parseInteractiveUIFlags(args, interactiveUIOptions{})
154+ return ui, nil, err
155+}
156+
157+// parseSubcommand parses every subcommand that does not accept UI flags.
158+func parseSubcommand(args []string) (command, error) {
159+ switch args[0] {
160+ case "--web-daemon", "--web-serve":
161+ return parseWebArgs(args)
162+ case "--web-renew-token":
163+ return parseNoArgs(args, kindWebRenew)
164+ case "--daemon":
165+ return command{kind: kindDaemon}, nil
166+ case "--daemon-launcher":
167+ return command{kind: kindDaemonLauncher}, nil
168+ case productionBrokerServeCommand:
169+ return parseProductionBrokerServeArgs(args[1:])
170+ case productionBrokerLauncherCommand:
171+ return parseProductionBrokerLauncherArgs(args[1:])
172+ case brokerReadyCommand:
173+ options, err := parseBrokerReadyArgs(args[1:])
174+ return command{kind: kindBrokerReady, brokerReady: options}, err
175+ case brokerMuxStdioCommand:
176+ return parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, args[1:])
177+ case brokerMuxQUICBootstrapCommand:
178+ return parseBrokerMuxArgs(brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:])
179+ case brokerMuxQUICProxyCommand:
180+ return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:])
181+ case uiRemoteCleanupCommand:
182+ return parseNoArgs(args, kindUIRemoteCleanup)
183+ case daemonStopCommand:
184+ return parseNoArgs(args, kindDaemonStop)
185+ case "ls", "list":
186+ return parseListArgs(args[1:])
187+ case "host":
188+ return parseHostArgs(args[1:])
189+ case "env":
190+ invocation, err := parseEnvArgs(args[1:])
191+ if err != nil {
192+ return command{}, err
193+ }
194+ return command{kind: kindEnv, env: invocation}, nil
195+ case "cmd":
196+ invocation, err := parseCmdArgs(args[1:])
197+ if err != nil {
198+ return command{}, err
199+ }
200+ return command{kind: kindCmd, cmd: invocation}, nil
201+ case "kill":
202+ return parseKillArgs(args[1:])
203+ case "-h", "--help", "help":
204+ return command{kind: kindHelp}, nil
205+ case "--version", "version":
206+ return command{kind: kindVersion}, nil
207+ default:
208+ return command{}, usagef("unknown command %q", args[0])
209+ }
210+}
211+
212+// parseNoArgs accepts a flag-style subcommand that takes no arguments.
213+func parseNoArgs(args []string, kind cmdKind) (command, error) {
214+ if len(args) != 1 {
215+ return command{}, usagef("`%s` does not accept arguments", args[0])
216+ }
217+ return command{kind: kind}, nil
218+}
219+
220+// parseNewArgs parses `new <name> [user@host] [UI flags]`.
221+func parseNewArgs(args []string, ui interactiveUIOptions) (command, error) {
222+ if len(args) == 0 || args[0] == "" {
223+ return command{}, usagef("`new` requires a session name")
224+ }
225+ name, args := args[0], args[1:]
226+ var remoteTarget string
227+ if len(args) > 0 && !strings.HasPrefix(args[0], "--") {
228+ remoteTarget, args = args[0], args[1:]
229+ if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
230+ return command{}, err
231+ }
232+ }
233+ ui, err := parseInteractiveUIFlags(args, ui)
234+ if err != nil {
235+ return command{}, err
236+ }
237+ if err := domain.ValidateSessionName(name); err != nil {
238+ return command{}, err
239+ }
240+ return command{kind: kindAttach, intent: protocol.IntentNew, name: name, remoteTarget: remoteTarget, ui: ui}, nil
241+}
242+
243+// parseAttachArgs parses `attach <name|user@host[:session]> [UI flags]`.
244+func parseAttachArgs(args []string, ui interactiveUIOptions) (command, error) {
245+ if len(args) == 0 || args[0] == "" {
246+ return command{}, usagef("`attach` requires a session name")
247+ }
248+ ui, err := parseInteractiveUIFlags(args[1:], ui)
249+ if err != nil {
250+ return command{}, err
251+ }
252+ cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[0], ui: ui}
253+ target, session, ok := parseRemoteAttachTarget(args[0])
254+ if !ok {
255+ return cmd, nil
256+ }
257+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
258+ return command{}, err
259+ }
260+ if session == "" {
261+ cmd.intent = protocol.IntentEphemeral
262+ } else if err := domain.ValidateSessionName(session); err != nil {
263+ return command{}, err
264+ }
265+ cmd.remoteTarget = target
266+ cmd.name = session
267+ return cmd, nil
268+}
269+
270+// parseKillArgs parses `kill <name>`, `kill -- <name>`, `kill --sessions`, and
271+// `kill --all`.
272+func parseKillArgs(args []string) (command, error) {
273+ if len(args) == 0 || args[0] == "" {
274+ return command{}, usagef("`kill` requires a session name, --sessions, or --all")
275+ }
276+ if args[0] == "--" {
277+ if len(args) != 2 || args[1] == "" {
278+ return command{}, usagef("`kill --` requires a session name")
279+ }
280+ return command{kind: kindKill, name: args[1]}, nil
281+ }
282+ if len(args) > 1 {
283+ return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all")
284+ }
285+ switch args[0] {
286+ case "--sessions":
287+ return command{kind: kindKill, killSessions: true}, nil
288+ case "--all":
289+ return command{kind: kindKill, killAll: true}, nil
290+ }
291+ if strings.HasPrefix(args[0], "-") {
292+ return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[0])
293+ }
294+ return command{kind: kindKill, name: args[0]}, nil
295+}
296+
297+func parseListArgs(args []string) (command, error) {
298+ if len(args) == 0 {
299+ return command{kind: kindList}, nil
300+ }
301+ if len(args) > 1 {
302+ return command{}, usagef("`ls` accepts at most one host or --all")
303+ }
304+ switch args[0] {
305+ case "--all":
306+ return command{kind: kindList, listAll: true}, nil
307+ default:
308+ if strings.HasPrefix(args[0], "-") {
309+ return command{}, usagef("unknown flag %q for `ls`", args[0])
310+ }
311+ if err := domain.ValidateRemoteHostTarget(args[0]); err != nil {
312+ return command{}, err
313+ }
314+ return command{kind: kindList, listHost: args[0]}, nil
315+ }
316+}
317+
318+func parseHostArgs(args []string) (command, error) {
319+ if len(args) == 0 {
320+ return command{}, usagef("`host` requires add, rm, or list")
321+ }
322+ switch args[0] {
323+ case hostActionAdd:
324+ return parseHostAddArgs(args[1:])
325+ case hostActionRm:
326+ if len(args) < 2 || args[1] == "" {
327+ return command{}, usagef("`host rm` requires a host target")
328+ }
329+ if strings.HasPrefix(args[1], "-") {
330+ return command{}, usagef("unknown flag %q for `host rm`", args[1])
331+ }
332+ if len(args) > 2 {
333+ return command{}, usagef("`host rm` accepts exactly one host target")
334+ }
335+ if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
336+ return command{}, err
337+ }
338+ return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil
339+ case hostActionList:
340+ if len(args) > 1 {
341+ if strings.HasPrefix(args[1], "-") {
342+ return command{}, usagef("unknown flag %q for `host list`", args[1])
343+ }
344+ return command{}, usagef("`host list` accepts no arguments")
345+ }
346+ return command{kind: kindHost, hostAction: hostActionList}, nil
347+ default:
348+ return command{}, usagef("unknown host action %q", args[0])
349+ }
350+}
351+
352+// parseHostAddArgs parses `host add [--transport quic|ssh] <host>`.
353+func parseHostAddArgs(args []string) (command, error) {
354+ transport, target := "", ""
355+ for i := 0; i < len(args); i++ {
356+ arg := args[i]
357+ switch {
358+ case arg == "--transport" || strings.HasPrefix(arg, "--transport="):
359+ if transport != "" {
360+ return command{}, usagef("`host add` accepts --transport once")
361+ }
362+ value, hasValue := strings.CutPrefix(arg, "--transport=")
363+ if !hasValue {
364+ if i+1 >= len(args) {
365+ return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH)
366+ }
367+ i++
368+ value = args[i]
369+ }
370+ switch value {
371+ case hostTransportQUIC, hostTransportSSH:
372+ transport = value
373+ default:
374+ return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH)
375+ }
376+ case strings.HasPrefix(arg, "-"):
377+ return command{}, usagef("unknown flag %q for `host add`", arg)
378+ case target != "":
379+ return command{}, usagef("`host add` accepts exactly one host target")
380+ default:
381+ target = arg
382+ }
383+ }
384+ if target == "" {
385+ return command{}, usagef("`host add` requires a host target")
386+ }
387+ if err := domain.ValidateRemoteHostTarget(target); err != nil {
388+ return command{}, err
389+ }
390+ if transport == "" {
391+ transport = hostTransportQUIC
392+ }
393+ return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil
394+}
M internal/app/cmd.go +1 -1
@@ -137,7 +137,7 @@ var errCommandNotSent = errors.New("command request not sent")
137137 // uncorrelated, malformed, or never observed before the deadline. The daemon may
138138 // already have committed the command, so the outcome is indeterminate, the
139139 // request is never replayed, and a late result cannot change the verdict.
140-var errCommandOutcomeUnknown = errors.New("vev: command outcome unknown")
140+var errCommandOutcomeUnknown = errors.New("command outcome unknown")
141141
142142 // commandOutcomeUnknown classifies one observation detail as an indeterminate
143143 // command outcome while preserving that detail's exact message and unwrap chain.
A internal/app/daemon_run.go +319 -0
@@ -0,0 +1,319 @@
1+package app
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "errors"
7+ "fmt"
8+ "log/slog"
9+ "net"
10+ "net/http"
11+ "os"
12+ "os/signal"
13+ "path/filepath"
14+ "strings"
15+ "syscall"
16+ "time"
17+
18+ snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
19+
20+ "github.com/bnema/vev/internal/adapters/clock"
21+ "github.com/bnema/vev/internal/adapters/config"
22+ "github.com/bnema/vev/internal/adapters/daemonidentity"
23+ "github.com/bnema/vev/internal/adapters/daemonmux"
24+ "github.com/bnema/vev/internal/adapters/ipc"
25+ "github.com/bnema/vev/internal/adapters/lifecycle"
26+ "github.com/bnema/vev/internal/adapters/noticefile"
27+ "github.com/bnema/vev/internal/adapters/pty"
28+ "github.com/bnema/vev/internal/adapters/sessionwire"
29+ "github.com/bnema/vev/internal/adapters/shellcmd"
30+ "github.com/bnema/vev/internal/domain"
31+ "github.com/bnema/vev/internal/logging"
32+ "github.com/bnema/vev/internal/persist"
33+ "github.com/bnema/vev/internal/platform"
34+ "github.com/bnema/vev/internal/ports"
35+ "github.com/bnema/vev/internal/protocol/wire"
36+ "github.com/bnema/vev/internal/usecase/daemon"
37+ "github.com/bnema/vev/internal/usecase/recovery"
38+ "github.com/bnema/vev/pkg/safedir"
39+)
40+
41+// Daemon composition seams replaced by tests.
42+var (
43+ openCatalogue = persist.OpenOrCreate
44+ listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (wire.Listener, error) {
45+ return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
46+ }
47+)
48+
49+func snapshotDir() string {
50+ return filepath.Join(platform.StateDir(), "snapshots")
51+}
52+
53+func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) {
54+ dir, active := platform.DevelopmentEnvironmentTempDir()
55+ if !active {
56+ return nil, nil
57+ }
58+ if err := ensurePrivate(dir); err != nil {
59+ return nil, fmt.Errorf("secure development temp directory: %w", err)
60+ }
61+ return daemon.WithTempDir(dir), nil
62+}
63+
64+func pprofAddrIsLoopback(addr string) bool {
65+ host, _, err := net.SplitHostPort(addr)
66+ if err != nil || host == "" {
67+ return false
68+ }
69+ if strings.EqualFold(host, "localhost") {
70+ return true
71+ }
72+ ip := net.ParseIP(host)
73+ return ip != nil && ip.IsLoopback()
74+}
75+
76+type ownedDaemonStart func(context.Context) error
77+
78+type lifecycleOwnership interface {
79+ Release() error
80+}
81+
82+type lifecycleStartupDeps struct {
83+ ensurePrivate func(string) error
84+ acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)
85+ log *slog.Logger
86+}
87+
88+const lifecycleAcquireRetry = 25 * time.Millisecond
89+
90+func lifecycleStartupDependencies(log *slog.Logger) lifecycleStartupDeps {
91+ return lifecycleStartupDeps{
92+ ensurePrivate: safedir.EnsurePrivate,
93+ acquire: func(ctx context.Context, runtimeDir string, retry time.Duration) (lifecycleOwnership, error) {
94+ return lifecycle.Acquire(ctx, runtimeDir, retry)
95+ },
96+ log: log,
97+ }
98+}
99+
100+func runWithLifecycleOwner(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart) error {
101+ return runWithLifecycleOwnerDeps(ctx, runtimeDir, stateDir, start, lifecycleStartupDependencies(nil))
102+}
103+
104+func runWithLifecycleOwnerDeps(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart, deps lifecycleStartupDeps) (retErr error) {
105+ log := deps.log
106+ if log == nil {
107+ log = slog.Default()
108+ }
109+ if err := deps.ensurePrivate(runtimeDir); err != nil {
110+ return fmt.Errorf("secure runtime directory: %w", err)
111+ }
112+ if err := deps.ensurePrivate(stateDir); err != nil {
113+ return fmt.Errorf("secure state directory: %w", err)
114+ }
115+ lockPath := lifecycle.Path(runtimeDir)
116+ log.Info("lifecycle_owner_wait", "path", lockPath)
117+ owner, err := deps.acquire(ctx, runtimeDir, lifecycleAcquireRetry)
118+ if err != nil {
119+ log.Warn("lifecycle_owner_wait_failed", "path", lockPath, "reason_code", "acquire-failed")
120+ return fmt.Errorf("acquire lifecycle ownership: %w", err)
121+ }
122+ log.Info("lifecycle_owner_acquired", "path", lockPath)
123+ defer func() {
124+ releaseErr := owner.Release()
125+ if releaseErr != nil {
126+ log.Error("lifecycle_owner_release_failed", "path", lockPath, "reason_code", "release-failed")
127+ } else {
128+ log.Info("lifecycle_owner_released", "path", lockPath)
129+ }
130+ retErr = errors.Join(retErr, releaseErr)
131+ }()
132+ if start == nil {
133+ return errors.New("nil owned daemon startup")
134+ }
135+ return start(ctx)
136+}
137+
138+// runDaemon runs the daemon in the foreground (the hidden --daemon path,
139+// entered by an auto-spawned child) while holding exclusive lifecycle
140+// ownership through complete teardown.
141+func runDaemon() (retErr error) {
142+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
143+ defer stop()
144+ log, logCloser, err := configureLogging(logging.Daemon, true)
145+ if err != nil {
146+ return err
147+ }
148+ defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
149+ return runWithLifecycleOwnerDeps(ctx, ipc.SocketDir(), platform.StateDir(), func(ctx context.Context) error {
150+ return runDaemonOwnedWithLogger(ctx, log)
151+ }, lifecycleStartupDependencies(log))
152+}
153+
154+func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {
155+ log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)
156+}
157+
158+func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
159+ healthy, fresh, broken := 0, 0, 0
160+ for _, record := range records {
161+ switch {
162+ case record.DegradedReason != "":
163+ broken++
164+ case record.Committed == nil:
165+ fresh++
166+ default:
167+ healthy++
168+ }
169+ }
170+ log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken)
171+}
172+
173+func runDaemonOwned(ctx context.Context) (retErr error) {
174+ log, logCloser, err := configureLogging(logging.Daemon, true)
175+ if err != nil {
176+ return err
177+ }
178+ defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
179+ return runDaemonOwnedWithLogger(ctx, log)
180+}
181+
182+func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) {
183+ clk := clock.New()
184+ observer, observerCloser, err := newPerformanceTrace(clk)
185+ if err != nil {
186+ return fmt.Errorf("performance trace: %w", err)
187+ }
188+ if observerCloser != nil {
189+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
190+ }
191+ if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
192+ if !pprofAddrIsLoopback(addr) {
193+ log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
194+ }
195+ go func() {
196+ if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
197+ log.Error("pprof server exited", "err", err)
198+ }
199+ }()
200+ log.Info("pprof enabled", "addr", addr)
201+ }
202+
203+ var daemonOpts []daemon.Option
204+ developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate)
205+ if err != nil {
206+ return err
207+ }
208+ if developmentTempOpt != nil {
209+ daemonOpts = append(daemonOpts, developmentTempOpt)
210+ }
211+ if observer != nil {
212+ daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
213+ }
214+ configPath := platform.ConfigPath()
215+ cfg := loadConfigOrDefaults(log, configPath)
216+ daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
217+ daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
218+ daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
219+ snapshotRepository := snapshotadapter.NewRepositoryWithLogger(snapshotDir(), log)
220+ daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
221+ noticeStore := noticefile.New(platform.StateDir())
222+ daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
223+ stateDir := platform.StateDir()
224+ storePath := persist.StorePath(stateDir)
225+ opened, err := openCatalogue(stateDir)
226+ if err != nil {
227+ log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")
228+ if errors.Is(err, persist.ErrCatalogueUnreadable) {
229+ return unreadableCatalogueError(stateDir)
230+ }
231+ return fmt.Errorf("open durable session state %s: %w", storePath, err)
232+ }
233+ recoveryMode := "current"
234+ if opened.NewInstall {
235+ recoveryMode = "new-install"
236+ }
237+ coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
238+ if opened.Migration.Performed {
239+ recoveryMode = "catalogue-migrated"
240+ log.Info("catalogue_migrated", "source_formats", opened.Migration.SourceFormats, "target_format", opened.Migration.TargetFormat, "records", opened.Migration.RecordCount, "backup", opened.Migration.BackupPath)
241+ }
242+ logCatalogueRecovery(log, opened.Records, recoveryMode)
243+ daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator))
244+ daemonOpts = append(daemonOpts, daemon.WithSnapshotGarbageCollection())
245+ log.Info("session persistence enabled", "path", storePath)
246+ daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))
247+
248+ // Construct the catalogue-backed expected-session registry before socket
249+ // publication. Phase 3 snapshot restoration remains asynchronous in Serve.
250+ identity, err := daemonidentity.LoadOrCreate(stateDir)
251+ if err != nil {
252+ return fmt.Errorf("establish daemon identity: %w", err)
253+ }
254+ incarnation, err := daemonidentity.NewIncarnation()
255+ if err != nil {
256+ return fmt.Errorf("establish daemon incarnation: %w", err)
257+ }
258+ binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{
259+ {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal},
260+ {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote},
261+ {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote},
262+ })
263+ if err != nil {
264+ return fmt.Errorf("establish daemon mux authority: %w", err)
265+ }
266+ aggregate := daemonmux.NewAggregateListener()
267+ muxSupervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0)
268+ if err != nil {
269+ return err
270+ }
271+ defer func() { retErr = errors.Join(retErr, muxSupervisor.Close()) }()
272+ muxListener, err := ipc.ListenMux(daemonmux.SocketPath(ipc.SocketDir()))
273+ if err != nil {
274+ return fmt.Errorf("listen daemon mux: %w", err)
275+ }
276+ defer func() { retErr = errors.Join(retErr, muxListener.Close()) }()
277+ go func() {
278+ for {
279+ raw, acceptErr := muxListener.Accept()
280+ if acceptErr != nil {
281+ return
282+ }
283+ go func() { _ = muxSupervisor.Adopt(ctx, raw) }()
284+ }
285+ }()
286+
287+ // The catalogue-backed registry exists before the socket is published.
288+ // Snapshot GC runs in the background once restoration finishes, so it never
289+ // delays socket publication.
290+ d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
291+ ln, err := listenDaemon(ipc.SocketDir(), observer)
292+ if err != nil {
293+ closeErr := opened.Catalogue.Close()
294+ log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
295+ return errors.Join(fmt.Errorf("daemon listen: %w", err), closeErr)
296+ }
297+ defer func() { _ = ln.Close() }()
298+ log.Info("daemon starting", "socket", ln.Addr())
299+
300+ watchCtx, stopWatch := context.WithCancel(ctx)
301+ defer stopWatch()
302+ go func() {
303+ if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
304+ logConfigWarnings(log, warnings)
305+ d.ApplyConfig(cfg)
306+ }); err != nil && !errors.Is(err, context.Canceled) {
307+ log.Warn("config watcher stopped", "path", configPath, "err", err)
308+ }
309+ }()
310+ if err := aggregate.Register(sessionwire.NewServerListener(ln)); err != nil {
311+ return fmt.Errorf("register local session listener: %w", err)
312+ }
313+ if err := d.Serve(ctx, aggregate); err != nil {
314+ log.Error("daemon exited", "err", err)
315+ return err
316+ }
317+ log.Info("daemon exited cleanly")
318+ return nil
319+}
M internal/app/kill_all_linux.go +5 -5
@@ -62,11 +62,11 @@ type killAllReport struct {
6262 // The daemon checkpoints named sessions on SIGTERM; numbered sessions close.
6363 func runKillAll(ctx context.Context, out io.Writer) error {
6464 if os.Getenv("VEV") != "" {
65- return errors.New("vev: `kill --all` stops the vev you are running in; run it from a terminal outside vev")
65+ return errors.New("`kill --all` stops the vev you are running in; run it from a terminal outside vev")
6666 }
6767 exe, err := os.Executable()
6868 if err != nil {
69- return fmt.Errorf("vev: resolve executable: %w", err)
69+ return fmt.Errorf("resolve executable: %w", err)
7070 }
7171 scope := killAllScope{
7272 procRoot: "/proc",
@@ -108,7 +108,7 @@ func stopEverything(ctx context.Context, scope killAllScope, timeout time.Durati
108108 return report
109109 }
110110 if round == killAllRounds {
111- report.errs = append(report.errs, fmt.Errorf("vev: %d vev processes kept starting again; run `vev kill --all` once more", len(procs)))
111+ report.errs = append(report.errs, fmt.Errorf("%d vev processes kept starting again; run `vev kill --all` once more", len(procs)))
112112 return report
113113 }
114114 // Clients first, so none of them can start a broker or daemon from
@@ -144,7 +144,7 @@ func installedPath(exe string) string {
144144 func findVevProcesses(scope killAllScope) ([]vevProcess, error) {
145145 entries, err := os.ReadDir(scope.procRoot)
146146 if err != nil {
147- return nil, fmt.Errorf("vev: list processes: %w", err)
147+ return nil, fmt.Errorf("list processes: %w", err)
148148 }
149149 var procs []vevProcess
150150 for _, entry := range entries {
@@ -239,7 +239,7 @@ func stopWave(ctx context.Context, procs []vevProcess, timeout time.Duration, re
239239 }
240240 _ = syscall.Kill(p.pid, syscall.SIGKILL)
241241 if err := waitProcessExit(context.WithoutCancel(ctx), p.pid, time.Second); err != nil {
242- report.errs = append(report.errs, fmt.Errorf("vev: %s (pid %d) did not stop: %w", p.role, p.pid, err))
242+ report.errs = append(report.errs, fmt.Errorf("%s (pid %d) did not stop: %w", p.role, p.pid, err))
243243 survivors = append(survivors, p)
244244 continue
245245 }
M internal/app/kill_all_other.go +1 -1
@@ -9,5 +9,5 @@ import (
99 )
1010
1111 func runKillAll(context.Context, io.Writer) error {
12- return errors.New("vev: `kill --all` requires Linux /proc")
12+ return errors.New("`kill --all` requires Linux /proc")
1313 }
M internal/app/local_route_probe_test_helpers_test.go +2 -2
@@ -20,11 +20,11 @@ import (
2020 // does in production.
2121 func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*broker.LocalDaemonProbe, error) {
2222 if dial == nil {
23- return nil, errors.New("vev: broker local probe requires a dialer")
23+ return nil, errors.New("broker local probe requires a dialer")
2424 }
2525 connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings)
2626 if err != nil {
27- return nil, fmt.Errorf("vev: broker local probe: %w", err)
27+ return nil, fmt.Errorf("broker local probe: %w", err)
2828 }
2929 return newLocalRouteProbeWithConnector(binding, epoch, connector)
3030 }
M internal/app/remote_hosts.go +6 -6
@@ -82,7 +82,7 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
8282 _, err = service.AddHost(ctx, cmd.hostTarget, policy)
8383 if isHostConflict(err) {
8484 if stored := storedHostTransport(service.Snapshot(), cmd.hostTarget); stored != "" && stored != cmd.hostTransport {
85- return fmt.Errorf("vev: host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err)
85+ return fmt.Errorf("host %q is already registered with transport %q; run `vev host rm %s` first, then add it again: %w", cmd.hostTarget, stored, cmd.hostTarget, err)
8686 }
8787 }
8888 return err
@@ -98,14 +98,14 @@ func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error
9898 }
9999 }
100100 if registration.Endpoint == "" {
101- return fmt.Errorf("vev: unknown host %q", cmd.hostTarget)
101+ return fmt.Errorf("unknown host %q", cmd.hostTarget)
102102 }
103103 removed, err := service.RemoveHost(ctx, registration)
104104 if err != nil {
105105 return err
106106 }
107107 if !removed {
108- return fmt.Errorf("vev: unknown host %q", cmd.hostTarget)
108+ return fmt.Errorf("unknown host %q", cmd.hostTarget)
109109 }
110110 return nil
111111 case hostActionList:
@@ -179,7 +179,7 @@ func listDisconnectedHosts(w io.Writer) error {
179179
180180 func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
181181 if err := snapshot.Validate(); err != nil {
182- return fmt.Errorf("vev: reading broker catalogue: %w", err)
182+ return fmt.Errorf("reading broker catalogue: %w", err)
183183 }
184184 var endpoints []string
185185 for _, daemon := range snapshot.Daemons {
@@ -213,7 +213,7 @@ func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io
213213
214214 func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer, now time.Time) error {
215215 if err := snapshot.Validate(); err != nil {
216- return fmt.Errorf("vev: reading broker catalogue: %w", err)
216+ return fmt.Errorf("reading broker catalogue: %w", err)
217217 }
218218 var sessions []protocol.SessionInfo
219219 var statuses []string
@@ -241,7 +241,7 @@ func renderBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout
241241 }
242242 }
243243 if !found {
244- return fmt.Errorf("vev: unknown host %q", cmd.listHost)
244+ return fmt.Errorf("unknown host %q", cmd.listHost)
245245 }
246246 for _, status := range statuses {
247247 if _, err := fmt.Fprintln(stdout, status); err != nil {
M internal/app/run.go +9 -1051
@@ -6,145 +6,24 @@ package app
66
77 import (
88 "context"
9- "crypto/rand"
109 "errors"
1110 "fmt"
1211 "io"
1312 "log/slog"
14- "net"
15- "net/http"
1613 _ "net/http/pprof"
1714 "os"
18- "os/signal"
1915 "path/filepath"
20- "strconv"
21- "strings"
22- "sync"
23- "syscall"
24- "text/tabwriter"
2516 "time"
2617
2718 "github.com/bnema/vev/internal/adapters/clock"
2819 "github.com/bnema/vev/internal/adapters/config"
29- "github.com/bnema/vev/internal/adapters/daemonidentity"
30- "github.com/bnema/vev/internal/adapters/daemonmux"
31- "github.com/bnema/vev/internal/adapters/ipc"
32- "github.com/bnema/vev/internal/adapters/lifecycle"
33- "github.com/bnema/vev/internal/adapters/noticefile"
34- "github.com/bnema/vev/internal/adapters/observability"
35- "github.com/bnema/vev/internal/adapters/pty"
36- "github.com/bnema/vev/internal/adapters/sessionwire"
37- "github.com/bnema/vev/internal/adapters/shellcmd"
38- snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
3920 "github.com/bnema/vev/internal/domain"
4021 "github.com/bnema/vev/internal/logging"
41- "github.com/bnema/vev/internal/persist"
4222 "github.com/bnema/vev/internal/platform"
43- "github.com/bnema/vev/internal/ports"
4423 "github.com/bnema/vev/internal/protocol"
45- "github.com/bnema/vev/internal/protocol/wire"
4624 "github.com/bnema/vev/internal/usecase/client"
47- "github.com/bnema/vev/internal/usecase/daemon"
48- "github.com/bnema/vev/internal/usecase/recovery"
49- "github.com/bnema/vev/pkg/safedir"
5025 )
5126
52-// cmdKind identifies which sub-command the CLI parsed.
53-type cmdKind int
54-
55-const (
56- kindAttach cmdKind = iota // ephemeral/new/attach — distinguished by intent
57- kindList
58- kindHost
59- kindKill
60- kindCmd
61- kindEnv
62- kindDaemon
63- kindDaemonLauncher
64- kindUIDriver
65- kindUIRemoteCleanup
66- kindDaemonStop
67- kindWebDaemon
68- kindWebRenew
69- kindWebServe
70- kindBrokerMuxStdio
71- kindBrokerMuxQUICBootstrap
72- kindBrokerMuxQUICProxy
73- kindProductionBrokerServe
74- kindProductionBrokerLauncher
75- kindBrokerReady
76- kindHelp
77- kindVersion
78-)
79-
80-// command is the parsed CLI invocation: what to do, plus the attach intent
81-// and session name where relevant.
82-type command struct {
83- kind cmdKind
84- intent uint8
85- name string
86- remoteTarget string
87- listHost string
88- listAll bool
89- hostAction string
90- hostTarget string
91- // hostTransport is the `host add` carriage: hostTransportQUIC or hostTransportSSH.
92- hostTransport string
93- killAll bool
94- killSessions bool
95- cmd cmdInvocation
96- env envInvocation
97- brokerServe brokerServeOptions
98- brokerMux brokerMuxOptions
99- brokerReady brokerReadyOptions
100- uiDriver uiDriverOptions
101- uiObserve bool
102- uiControl bool
103- uiSocket string
104- web webOptions
105-}
106-
107-// usageError is a user-facing argument error; the app prints it (with usage)
108-// rather than a stack of wrapped internals.
109-type usageError struct{ msg string }
110-
111-func (e *usageError) Error() string { return "vev: " + e.msg }
112-
113-func usagef(format string, args ...any) error {
114- return &usageError{msg: fmt.Sprintf(format, args...)}
115-}
116-
117-const usageText = `vev — a terminal multiplexer
118-
119-usage:
120- vev attach to (or create) an ephemeral session
121- vev new <name> create and attach to a named session
122- vev attach <name> attach to an existing session (alias: a)
123- vev attach user@host[:session]
124- attach through SSH to a remote vev daemon
125- vev ls list local sessions
126- vev ls <host> list sessions on a known remote host
127- vev ls --all list local and remote sessions
128- vev host add [--transport quic|ssh] <host>
129- add a pinned remote host (default transport: quic)
130- vev host rm <host> remove a pinned remote host
131- vev host list list known remote hosts
132- vev kill <name> kill a session
133- vev kill --sessions kill all sessions (vev keeps running)
134- vev kill --all stop everything: every vev window, the broker, and the
135- daemon (named sessions come back on the next start)
136- vev cmd <command> run a control command (vev cmd --help)
137- vev env <fish|sh> print shell code that refreshes desktop variables
138- vev --ui-observe expose passive observation for this interactive client
139- (optional: --ui-socket PATH)
140- vev --ui-control expose observation and input control for this client
141- (optional: --ui-socket PATH)
142- vev --web-daemon start the private web terminal or print its current link
143- vev --web-renew-token revoke web access and print a fresh link
144- Web options: --web-listen IP:PORT --web-origin http(s)://HOST[:PORT]
145- vev --help show this help
146- vev --version show version`
147-
14827 // Build metadata. Defaults describe a plain `go build`; releases overwrite
14928 // them via -ldflags "-X github.com/bnema/vev/internal/app.version=..." (and
15029 // .commit / .date) — see .goreleaser.yaml.
@@ -152,11 +31,6 @@ var (
15231 version = "0.1.0-dev"
15332 commit = "none"
15433 date = "unknown"
155-
156- openCatalogue = persist.OpenOrCreate
157- listenDaemon = func(dir string, observer ports.SerializedRuntimeObserver) (wire.Listener, error) {
158- return ipc.Listen(dir, ipc.WithRuntimeObserver(observer))
159- }
16034 )
16135
16236 // versionLine renders the --version output.
@@ -177,188 +51,6 @@ func Run(args []string) error {
17751 return dispatch(context.Background(), cmd)
17852 }
17953
180-// parseArgs turns the raw argv tail into a command. It is deliberately a
181-// pure function so the full dispatch table can be unit-tested without any
182-// I/O.
183-func parseArgs(args []string) (command, error) {
184- if len(args) == 0 {
185- return command{kind: kindAttach, intent: protocol.IntentEphemeral}, nil
186- }
187- if args[0] == "--ui-driver" {
188- options, err := parseUIDriverArgs(args[1:])
189- if err != nil {
190- return command{}, err
191- }
192- return command{kind: kindUIDriver, uiDriver: options}, nil
193- }
194-
195- var uiObserve, uiControl bool
196- var uiSocket string
197- for len(args) > 0 {
198- switch args[0] {
199- case "--ui-observe":
200- uiObserve = true
201- case "--ui-control":
202- uiControl = true
203- case "--ui-socket":
204- if len(args) < 2 || args[1] == "" {
205- return command{}, usagef("`--ui-socket` requires a path")
206- }
207- uiSocket = args[1]
208- args = args[2:]
209- continue
210- default:
211- goto parsedUIFlags
212- }
213- args = args[1:]
214- }
215-parsedUIFlags:
216- interactive, err := parseInteractiveUIFlags(nil, uiObserve, uiControl, uiSocket)
217- if err != nil {
218- return command{}, err
219- }
220- uiObserve, uiControl, uiSocket = interactive.observe, interactive.control, interactive.socket
221- if len(args) == 0 {
222- return command{kind: kindAttach, intent: protocol.IntentEphemeral, uiObserve: uiObserve, uiControl: uiControl, uiSocket: uiSocket}, nil
223- }
224- if (uiObserve || uiControl || uiSocket != "") && args[0] != "new" && args[0] != "attach" && args[0] != "a" {
225- return command{}, usagef("UI flags are only valid for an attach command")
226- }
227-
228- switch args[0] {
229- case "--web-daemon", "--web-serve":
230- return parseWebArgs(args)
231- case "--web-renew-token":
232- if len(args) != 1 {
233- return command{}, usagef("`%s` does not accept arguments", args[0])
234- }
235- return command{kind: kindWebRenew}, nil
236- case "--daemon":
237- return command{kind: kindDaemon}, nil
238- case "--daemon-launcher":
239- return command{kind: kindDaemonLauncher}, nil
240- case productionBrokerServeCommand:
241- return parseProductionBrokerServeArgs(args[1:])
242- case productionBrokerLauncherCommand:
243- return parseProductionBrokerLauncherArgs(args[1:])
244- case brokerReadyCommand:
245- options, err := parseBrokerReadyArgs(args[1:])
246- return command{kind: kindBrokerReady, brokerReady: options}, err
247- case brokerMuxStdioCommand:
248- return parseBrokerMuxArgs(brokerMuxStdioCommand, kindBrokerMuxStdio, args[1:])
249- case brokerMuxQUICBootstrapCommand:
250- return parseBrokerMuxArgs(brokerMuxQUICBootstrapCommand, kindBrokerMuxQUICBootstrap, args[1:])
251- case brokerMuxQUICProxyCommand:
252- return parseBrokerMuxArgs(brokerMuxQUICProxyCommand, kindBrokerMuxQUICProxy, args[1:])
253- case uiRemoteCleanupCommand:
254- if len(args) != 1 {
255- return command{}, usagef("`%s` does not accept arguments", uiRemoteCleanupCommand)
256- }
257- return command{kind: kindUIRemoteCleanup}, nil
258- case daemonStopCommand:
259- if len(args) != 1 {
260- return command{}, usagef("`%s` does not accept arguments", daemonStopCommand)
261- }
262- return command{kind: kindDaemonStop}, nil
263- case "new":
264- if len(args) < 2 || args[1] == "" {
265- return command{}, usagef("`new` requires a session name")
266- }
267- name := args[1]
268- var remoteTarget string
269- optionStart := 2
270- if len(args) > 2 && !strings.HasPrefix(args[2], "--") {
271- remoteTarget = args[2]
272- if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
273- return command{}, err
274- }
275- optionStart = 3
276- }
277- interactive, err := parseInteractiveUIFlags(args[optionStart:], uiObserve, uiControl, uiSocket)
278- if err != nil {
279- return command{}, err
280- }
281- if err := domain.ValidateSessionName(name); err != nil {
282- return command{}, err
283- }
284- return command{kind: kindAttach, intent: protocol.IntentNew, name: name, remoteTarget: remoteTarget, uiObserve: interactive.observe, uiControl: interactive.control, uiSocket: interactive.socket}, nil
285- case "attach", "a":
286- if len(args) < 2 || args[1] == "" {
287- return command{}, usagef("`attach` requires a session name")
288- }
289- interactive, err := parseInteractiveUIFlags(args[2:], uiObserve, uiControl, uiSocket)
290- if err != nil {
291- return command{}, err
292- }
293- cmd := command{kind: kindAttach, intent: protocol.IntentAttach, name: args[1]}
294- if target, session, ok := parseRemoteAttachTarget(args[1]); ok {
295- if err := domain.ValidateRemoteHostTarget(target); err != nil {
296- return command{}, err
297- }
298- if session != "" {
299- if err := domain.ValidateSessionName(session); err != nil {
300- return command{}, err
301- }
302- }
303- cmd.remoteTarget = target
304- cmd.name = session
305- if session == "" {
306- cmd.intent = protocol.IntentEphemeral
307- }
308- }
309- cmd.uiObserve = interactive.observe
310- cmd.uiControl = interactive.control
311- cmd.uiSocket = interactive.socket
312- return cmd, nil
313- case "ls", "list":
314- return parseListArgs(args[1:])
315- case "host":
316- return parseHostArgs(args[1:])
317- case "env":
318- invocation, err := parseEnvArgs(args[1:])
319- if err != nil {
320- return command{}, err
321- }
322- return command{kind: kindEnv, env: invocation}, nil
323- case "cmd":
324- invocation, err := parseCmdArgs(args[1:])
325- if err != nil {
326- return command{}, err
327- }
328- return command{kind: kindCmd, cmd: invocation}, nil
329- case "kill":
330- if len(args) < 2 || args[1] == "" {
331- return command{}, usagef("`kill` requires a session name, --sessions, or --all")
332- }
333- if args[1] == "--" {
334- if len(args) != 3 || args[2] == "" {
335- return command{}, usagef("`kill --` requires a session name")
336- }
337- return command{kind: kindKill, name: args[2]}, nil
338- }
339- if len(args) > 2 {
340- return command{}, usagef("`kill` accepts exactly one session name, --sessions, or --all")
341- }
342- switch args[1] {
343- case "--sessions":
344- return command{kind: kindKill, killSessions: true}, nil
345- case "--all":
346- return command{kind: kindKill, killAll: true}, nil
347- default:
348- if strings.HasPrefix(args[1], "-") {
349- return command{}, usagef("unknown flag %q for `kill`; use `kill -- NAME` for a dashed session name", args[1])
350- }
351- return command{kind: kindKill, name: args[1]}, nil
352- }
353- case "-h", "--help", "help":
354- return command{kind: kindHelp}, nil
355- case "--version", "version":
356- return command{kind: kindVersion}, nil
357- default:
358- return command{}, usagef("unknown command %q", args[0])
359- }
360-}
361-
36254 // dispatch runs the parsed command.
36355 func dispatch(ctx context.Context, cmd command) error {
36456 switch cmd.kind {
@@ -410,208 +102,12 @@ func dispatch(ctx context.Context, cmd command) error {
410102 case kindEnv:
411103 return runEnv(ctx, cmd.env)
412104 case kindAttach:
413- return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket})
105+ return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, cmd.ui)
414106 default:
415107 return usagef("unhandled command")
416108 }
417109 }
418110
419-func parseListArgs(args []string) (command, error) {
420- if len(args) == 0 {
421- return command{kind: kindList}, nil
422- }
423- if len(args) > 1 {
424- return command{}, usagef("`ls` accepts at most one host or --all")
425- }
426- switch args[0] {
427- case "--all":
428- return command{kind: kindList, listAll: true}, nil
429- default:
430- if strings.HasPrefix(args[0], "-") {
431- return command{}, usagef("unknown flag %q for `ls`", args[0])
432- }
433- if err := domain.ValidateRemoteHostTarget(args[0]); err != nil {
434- return command{}, err
435- }
436- return command{kind: kindList, listHost: args[0]}, nil
437- }
438-}
439-
440-func parseHostArgs(args []string) (command, error) {
441- if len(args) == 0 {
442- return command{}, usagef("`host` requires add, rm, or list")
443- }
444- switch args[0] {
445- case hostActionAdd:
446- return parseHostAddArgs(args[1:])
447- case hostActionRm:
448- if len(args) < 2 || args[1] == "" {
449- return command{}, usagef("`host rm` requires a host target")
450- }
451- if strings.HasPrefix(args[1], "-") {
452- return command{}, usagef("unknown flag %q for `host rm`", args[1])
453- }
454- if len(args) > 2 {
455- return command{}, usagef("`host rm` accepts exactly one host target")
456- }
457- if err := domain.ValidateRemoteHostTarget(args[1]); err != nil {
458- return command{}, err
459- }
460- return command{kind: kindHost, hostAction: hostActionRm, hostTarget: args[1]}, nil
461- case hostActionList:
462- if len(args) > 1 {
463- if strings.HasPrefix(args[1], "-") {
464- return command{}, usagef("unknown flag %q for `host list`", args[1])
465- }
466- return command{}, usagef("`host list` accepts no arguments")
467- }
468- return command{kind: kindHost, hostAction: hostActionList}, nil
469- default:
470- return command{}, usagef("unknown host action %q", args[0])
471- }
472-}
473-
474-// parseHostAddArgs parses `host add [--transport quic|ssh] <host>`.
475-func parseHostAddArgs(args []string) (command, error) {
476- transport, target := "", ""
477- for i := 0; i < len(args); i++ {
478- arg := args[i]
479- switch {
480- case arg == "--transport" || strings.HasPrefix(arg, "--transport="):
481- if transport != "" {
482- return command{}, usagef("`host add` accepts --transport once")
483- }
484- value, hasValue := strings.CutPrefix(arg, "--transport=")
485- if !hasValue {
486- if i+1 >= len(args) {
487- return command{}, usagef("`--transport` requires a value (%s or %s)", hostTransportQUIC, hostTransportSSH)
488- }
489- i++
490- value = args[i]
491- }
492- switch value {
493- case hostTransportQUIC, hostTransportSSH:
494- transport = value
495- default:
496- return command{}, usagef("invalid transport %q (want %q or %q)", value, hostTransportQUIC, hostTransportSSH)
497- }
498- case strings.HasPrefix(arg, "-"):
499- return command{}, usagef("unknown flag %q for `host add`", arg)
500- case target != "":
501- return command{}, usagef("`host add` accepts exactly one host target")
502- default:
503- target = arg
504- }
505- }
506- if target == "" {
507- return command{}, usagef("`host add` requires a host target")
508- }
509- if err := domain.ValidateRemoteHostTarget(target); err != nil {
510- return command{}, err
511- }
512- if transport == "" {
513- transport = hostTransportQUIC
514- }
515- return command{kind: kindHost, hostAction: hostActionAdd, hostTarget: target, hostTransport: transport}, nil
516-}
517-
518-// performanceTrace creates one serialized timestamp owner for this process.
519-// An empty trace environment leaves all production behavior and wire bytes
520-// unchanged.
521-// Composition-root factory seams keep observer propagation testable without
522-// opening real transports.
523-var connectBroker = connectProductionBroker
524-var connectDaemonStopBroker = connectExistingBroker
525-
526-var newPerformanceTrace = performanceTrace
527-
528-func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
529- return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver)
530-}
531-
532-// performanceTraceWithFactories keeps setup rollback behavior directly
533-// testable without requiring an operating-system file close failure.
534-func performanceTraceWithFactories(
535- clk ports.Clock,
536- newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error),
537- newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error),
538-) (ports.SerializedRuntimeObserver, io.Closer, error) {
539- path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
540- if path == "" {
541- return nil, nil, nil
542- }
543- observer, closer, err := newSink(path, clk, processID)
544- if err != nil {
545- return nil, nil, err
546- }
547- // The harness supplies these manifest fields to every launched role. Keep a
548- // valid standalone trace for operators that set only the original trace
549- // variables, while ensuring harness traces match their process mapping.
550- inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1}
551- if inputs.Scenario == "" {
552- inputs.Scenario = "runtime"
553- }
554- if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
555- inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
556- if err != nil {
557- setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err)
558- return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
559- }
560- if inputs.Run == 0 {
561- setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
562- return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
563- }
564- }
565- observer, err = newCorrelation(observer, inputs)
566- if err != nil {
567- setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
568- return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
569- }
570- reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth)
571- return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
572-}
573-
574-func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error {
575- if closer == nil {
576- return setupErr
577- }
578- if closeErr := closer.Close(); closeErr != nil {
579- return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr))
580- }
581- return setupErr
582-}
583-
584-// runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
585-// A full queue emits the serialized diagnostic gap rather than delaying a
586-// terminal, transport, or ACK progress path.
587-const runtimeTraceQueueDepth = 256
588-
589-// runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter
590-// before closing the concrete timestamp/file owner. sync.Once makes every
591-// deferred and explicit close path safe without closing shared workers twice.
592-type runtimeTraceCloser struct {
593- reporter ports.SerializedRuntimeObserver
594- closer io.Closer
595- once sync.Once
596- err error
597-}
598-
599-func (c *runtimeTraceCloser) Close() error {
600- if c == nil {
601- return nil
602- }
603- c.once.Do(func() {
604- if c.reporter != nil {
605- c.reporter.Flush()
606- c.reporter.Close()
607- }
608- if c.closer != nil {
609- c.err = c.closer.Close()
610- }
611- })
612- return c.err
613-}
614-
615111 func configureLogging(component logging.Component, rotateAtRuntime bool) (*slog.Logger, io.Closer, error) {
616112 return logging.Setup(logging.Config{
617113 Dir: platform.StateDir(),
@@ -646,327 +142,6 @@ func loadConfigOrDefaults(log *slog.Logger, path string) domain.Config {
646142 return cfg
647143 }
648144
649-func snapshotDir() string {
650- return filepath.Join(platform.StateDir(), "snapshots")
651-}
652-
653-func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) {
654- dir, active := platform.DevelopmentEnvironmentTempDir()
655- if !active {
656- return nil, nil
657- }
658- if err := ensurePrivate(dir); err != nil {
659- return nil, fmt.Errorf("vev: secure development temp directory: %w", err)
660- }
661- return daemon.WithTempDir(dir), nil
662-}
663-
664-func pprofAddrIsLoopback(addr string) bool {
665- host, _, err := net.SplitHostPort(addr)
666- if err != nil || host == "" {
667- return false
668- }
669- if strings.EqualFold(host, "localhost") {
670- return true
671- }
672- ip := net.ParseIP(host)
673- return ip != nil && ip.IsLoopback()
674-}
675-
676-type ownedDaemonStart func(context.Context) error
677-
678-type lifecycleOwnership interface {
679- Release() error
680-}
681-
682-type lifecycleStartupDeps struct {
683- ensurePrivate func(string) error
684- acquire func(context.Context, string, time.Duration) (lifecycleOwnership, error)
685- log *slog.Logger
686-}
687-
688-const lifecycleAcquireRetry = 25 * time.Millisecond
689-
690-func lifecycleStartupDependencies(log *slog.Logger) lifecycleStartupDeps {
691- return lifecycleStartupDeps{
692- ensurePrivate: safedir.EnsurePrivate,
693- acquire: func(ctx context.Context, runtimeDir string, retry time.Duration) (lifecycleOwnership, error) {
694- return lifecycle.Acquire(ctx, runtimeDir, retry)
695- },
696- log: log,
697- }
698-}
699-
700-func runWithLifecycleOwner(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart) error {
701- return runWithLifecycleOwnerDeps(ctx, runtimeDir, stateDir, start, lifecycleStartupDependencies(nil))
702-}
703-
704-func runWithLifecycleOwnerDeps(ctx context.Context, runtimeDir, stateDir string, start ownedDaemonStart, deps lifecycleStartupDeps) (retErr error) {
705- log := deps.log
706- if log == nil {
707- log = slog.Default()
708- }
709- if err := deps.ensurePrivate(runtimeDir); err != nil {
710- return fmt.Errorf("vev: secure runtime directory: %w", err)
711- }
712- if err := deps.ensurePrivate(stateDir); err != nil {
713- return fmt.Errorf("vev: secure state directory: %w", err)
714- }
715- lockPath := lifecycle.Path(runtimeDir)
716- log.Info("lifecycle_owner_wait", "path", lockPath)
717- owner, err := deps.acquire(ctx, runtimeDir, lifecycleAcquireRetry)
718- if err != nil {
719- log.Warn("lifecycle_owner_wait_failed", "path", lockPath, "reason_code", "acquire-failed")
720- return fmt.Errorf("vev: acquire lifecycle ownership: %w", err)
721- }
722- log.Info("lifecycle_owner_acquired", "path", lockPath)
723- defer func() {
724- releaseErr := owner.Release()
725- if releaseErr != nil {
726- log.Error("lifecycle_owner_release_failed", "path", lockPath, "reason_code", "release-failed")
727- } else {
728- log.Info("lifecycle_owner_released", "path", lockPath)
729- }
730- retErr = errors.Join(retErr, releaseErr)
731- }()
732- if start == nil {
733- return errors.New("vev: nil owned daemon startup")
734- }
735- return start(ctx)
736-}
737-
738-// runDaemon runs the daemon in the foreground (the hidden --daemon path,
739-// entered by an auto-spawned child) while holding exclusive lifecycle
740-// ownership through complete teardown.
741-func runDaemon() (retErr error) {
742- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
743- defer stop()
744- log, logCloser, err := configureLogging(logging.Daemon, true)
745- if err != nil {
746- return err
747- }
748- defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
749- return runWithLifecycleOwnerDeps(ctx, ipc.SocketDir(), platform.StateDir(), func(ctx context.Context) error {
750- return runDaemonOwnedWithLogger(ctx, log)
751- }, lifecycleStartupDependencies(log))
752-}
753-
754-func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, recoveryMode string) {
755- log.Info("catalogue_validated", "records", len(records), "recovery", recoveryMode)
756-}
757-
758-func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
759- healthy, fresh, broken := 0, 0, 0
760- for _, record := range records {
761- switch {
762- case record.DegradedReason != "":
763- broken++
764- case record.Committed == nil:
765- fresh++
766- default:
767- healthy++
768- }
769- }
770- log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken)
771-}
772-
773-func runDaemonOwned(ctx context.Context) (retErr error) {
774- log, logCloser, err := configureLogging(logging.Daemon, true)
775- if err != nil {
776- return err
777- }
778- defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
779- return runDaemonOwnedWithLogger(ctx, log)
780-}
781-
782-func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) {
783- clk := clock.New()
784- observer, observerCloser, err := newPerformanceTrace(clk)
785- if err != nil {
786- return fmt.Errorf("vev: performance trace: %w", err)
787- }
788- if observerCloser != nil {
789- defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
790- }
791- if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" {
792- if !pprofAddrIsLoopback(addr) {
793- log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr)
794- }
795- go func() {
796- if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) {
797- log.Error("pprof server exited", "err", err)
798- }
799- }()
800- log.Info("pprof enabled", "addr", addr)
801- }
802-
803- var daemonOpts []daemon.Option
804- developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate)
805- if err != nil {
806- return err
807- }
808- if developmentTempOpt != nil {
809- daemonOpts = append(daemonOpts, developmentTempOpt)
810- }
811- if observer != nil {
812- daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer))
813- }
814- configPath := platform.ConfigPath()
815- cfg := loadConfigOrDefaults(log, configPath)
816- daemonOpts = append(daemonOpts, daemon.WithConfig(cfg))
817- daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
818- daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
819- snapshotRepository := snapshotadapter.NewRepositoryWithLogger(snapshotDir(), log)
820- daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
821- noticeStore := noticefile.New(platform.StateDir())
822- daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
823- stateDir := platform.StateDir()
824- storePath := persist.StorePath(stateDir)
825- opened, err := openCatalogue(stateDir)
826- if err != nil {
827- log.Error("catalogue_validation_failed", "path", storePath, "reason_code", "open-failed")
828- if errors.Is(err, persist.ErrCatalogueUnreadable) {
829- return unreadableCatalogueError(stateDir)
830- }
831- return fmt.Errorf("vev: open durable session state %s: %w", storePath, err)
832- }
833- recoveryMode := "current"
834- if opened.NewInstall {
835- recoveryMode = "new-install"
836- }
837- coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, rand.Reader)
838- if opened.Migration.Performed {
839- recoveryMode = "catalogue-migrated"
840- log.Info("catalogue_migrated", "source_formats", opened.Migration.SourceFormats, "target_format", opened.Migration.TargetFormat, "records", opened.Migration.RecordCount, "backup", opened.Migration.BackupPath)
841- }
842- logCatalogueRecovery(log, opened.Records, recoveryMode)
843- daemonOpts = append(daemonOpts, daemon.WithRecoveryCoordinator(coordinator))
844- daemonOpts = append(daemonOpts, daemon.WithSnapshotGarbageCollection())
845- log.Info("session persistence enabled", "path", storePath)
846- daemonOpts = append(daemonOpts, daemon.WithCatalogue(opened.Catalogue, opened.Records))
847-
848- // Construct the catalogue-backed expected-session registry before socket
849- // publication. Phase 3 snapshot restoration remains asynchronous in Serve.
850- identity, err := daemonidentity.LoadOrCreate(stateDir)
851- if err != nil {
852- return fmt.Errorf("vev: establish daemon identity: %w", err)
853- }
854- incarnation, err := daemonidentity.NewIncarnation()
855- if err != nil {
856- return fmt.Errorf("vev: establish daemon incarnation: %w", err)
857- }
858- binding, err := daemonmux.NewServerBindings(identity, incarnation, []daemonmux.ServerPolicyAdmission{
859- {Policy: localDaemonPolicy(), Origin: ports.SessionOriginLocal},
860- {Policy: remoteBrokerPolicy(hostTransportQUIC), Origin: ports.SessionOriginRemote},
861- {Policy: remoteBrokerPolicy(hostTransportSSH), Origin: ports.SessionOriginRemote},
862- })
863- if err != nil {
864- return fmt.Errorf("vev: establish daemon mux authority: %w", err)
865- }
866- aggregate := daemonmux.NewAggregateListener()
867- muxSupervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0)
868- if err != nil {
869- return err
870- }
871- defer func() { retErr = errors.Join(retErr, muxSupervisor.Close()) }()
872- muxListener, err := ipc.ListenMux(daemonmux.SocketPath(ipc.SocketDir()))
873- if err != nil {
874- return fmt.Errorf("vev: listen daemon mux: %w", err)
875- }
876- defer func() { retErr = errors.Join(retErr, muxListener.Close()) }()
877- go func() {
878- for {
879- raw, acceptErr := muxListener.Accept()
880- if acceptErr != nil {
881- return
882- }
883- go func() { _ = muxSupervisor.Adopt(ctx, raw) }()
884- }
885- }()
886-
887- // The catalogue-backed registry exists before the socket is published.
888- // Snapshot GC runs in the background once restoration finishes, so it never
889- // delays socket publication.
890- d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
891- ln, err := listenDaemon(ipc.SocketDir(), observer)
892- if err != nil {
893- closeErr := opened.Catalogue.Close()
894- log.Error("daemon listen failed", "socket_dir", ipc.SocketDir(), "err", err)
895- return errors.Join(fmt.Errorf("vev: daemon listen: %w", err), closeErr)
896- }
897- defer func() { _ = ln.Close() }()
898- log.Info("daemon starting", "socket", ln.Addr())
899-
900- watchCtx, stopWatch := context.WithCancel(ctx)
901- defer stopWatch()
902- go func() {
903- if err := config.Watch(watchCtx, clk, configPath, func(cfg domain.Config, warnings []domain.Warning) {
904- logConfigWarnings(log, warnings)
905- d.ApplyConfig(cfg)
906- }); err != nil && !errors.Is(err, context.Canceled) {
907- log.Warn("config watcher stopped", "path", configPath, "err", err)
908- }
909- }()
910- if err := aggregate.Register(sessionwire.NewServerListener(ln)); err != nil {
911- return fmt.Errorf("vev: register local session listener: %w", err)
912- }
913- if err := d.Serve(ctx, aggregate); err != nil {
914- log.Error("daemon exited", "err", err)
915- return err
916- }
917- log.Info("daemon exited cleanly")
918- return nil
919-}
920-
921-// runAttach dials (auto-spawning the daemon if needed) and runs the client
922-// attach loop. Logging goes to the shared file: the client must never write
923-// to the console while the terminal is raw.
924-func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (retErr error) {
925- if os.Getenv("VEV") != "" {
926- if remoteTarget == "" && intent == protocol.IntentNew {
927- return createDetachedTerminalSession(ctx, name)
928- }
929- return errors.New("vev: sessions should be nested with care; unset VEV to force")
930- }
931- ctx, stop := clientSignalContext(ctx)
932- defer stop()
933- log, logCloser, err := configureLogging(logging.Client, false)
934- if err != nil {
935- return err
936- }
937- defer func() { _ = logCloser.Close() }()
938- clk := clock.New()
939- observer, observerCloser, err := newPerformanceTrace(clk)
940- if err != nil {
941- return fmt.Errorf("vev: performance trace: %w", err)
942- }
943- if observerCloser != nil {
944- defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
945- }
946- if observer != nil {
947- observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTransportDiagnostic, 0, true))
948- }
949- terminal := terminalForAttach()
950- intent, preconnected, err := resolveAttachCreationIntent(ctx, intent, name, remoteTarget, terminal)
951- if err != nil {
952- return err
953- }
954- navigation, resolver, err := terminalBrokerNavigation(intent, name, remoteTarget)
955- if err != nil {
956- if preconnected != nil {
957- _ = preconnected.Close()
958- }
959- return err
960- }
961- callbacks := terminalBrokerCallbacks()
962- return runBrokerClient(ctx, brokerClientConfig{
963- Logger: log, Connector: withPreconnected(newProductionBrokerConnector(), preconnected), Terminal: terminal, Clock: clk,
964- InitialNavigation: navigation, ResolveInitialNavigation: resolver,
965- AttachmentEnvironment: outerTerminalAttachmentEnvironment(log), SessionEnvironment: terminalSessionEnvironment(),
966- OnState: callbacks.OnState, OnLifecycle: callbacks.OnLifecycle, OnFailure: callbacks.OnFailure,
967- })
968-}
969-
970145 const (
971146 uiRemoteCleanupCommand = "_ui-cleanup"
972147 // daemonStopCommand is the hidden, environment-scoped graceful stop of
@@ -977,42 +152,15 @@ const (
977152
978153 const daemonStopTimeout = 2 * time.Second
979154
980-var (
981- errDaemonNotRunning = errors.New("vev: no daemon running")
982- errKillOutcomeUnknown = errors.New("vev: kill outcome unknown")
983-)
984-
985-func createDetachedLocalSession(ctx context.Context, name string) error {
986- service, err := connectBroker(ctx)
987- if err != nil {
988- return fmt.Errorf("%w: %v", errDaemonUnreachable, err)
989- }
990- defer func() { _ = service.Close() }()
991- operations, err := client.NewBrokerOperations(service, clock.New())
992- if err != nil {
993- return err
994- }
995- identity, _ := parseVEVEnv(os.Getenv("VEV"))
996- result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), protocol.CommandRequest{
997- Version: protocol.Version, Slug: "new-session", Args: []string{name}, TargetSession: identity.session,
998- })
999- if err != nil {
1000- return err
1001- }
1002- return renderDetachedCreationResult(result)
1003-}
1004-
1005-func renderDetachedCreationResult(result protocol.CommandResult) error {
1006- return renderCommandResult(io.Discard, result)
1007-}
155+var errDaemonNotRunning = errors.New("no daemon running")
1008156
1009157 func runUIRemoteCleanup(ctx context.Context) error {
1010158 root := os.Getenv("VEV_ENV_ROOT")
1011159 if !filepath.IsAbs(root) || filepath.Clean(root) == string(filepath.Separator) {
1012- return errors.New("vev: remote UI cleanup requires a private absolute launch root")
160+ return errors.New("remote UI cleanup requires a private absolute launch root")
1013161 }
1014162 if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir == "" || runtimeDir != filepath.Join(root, "runtime") {
1015- return errors.New("vev: remote UI cleanup requires the launch runtime directory")
163+ return errors.New("remote UI cleanup requires the launch runtime directory")
1016164 }
1017165 if err := requestDaemonStop(ctx); err != nil && !errors.Is(err, errDaemonNotRunning) {
1018166 return err
@@ -1020,6 +168,9 @@ func runUIRemoteCleanup(ctx context.Context) error {
1020168 return nil
1021169 }
1022170
171+// connectDaemonStopBroker is the broker connection seam for requestDaemonStop.
172+var connectDaemonStopBroker = connectExistingBroker
173+
1023174 func requestDaemonStop(ctx context.Context) error {
1024175 ctx, cancel := context.WithTimeout(ctx, daemonStopTimeout)
1025176 defer cancel()
@@ -1046,128 +197,6 @@ func unreachableBrokerError(err error) error {
1046197 return &exitCoded{code: 3, err: fmt.Errorf("%w: %v", errDaemonUnreachable, err)}
1047198 }
1048199
1049-func brokerKillResultError(result protocol.KillResult) error {
1050- switch result.Outcome {
1051- case protocol.KillSucceeded:
1052- return nil
1053- case protocol.KillFailed:
1054- if result.Text == "" {
1055- result.Text = "kill failed"
1056- }
1057- return errors.New(result.Text)
1058- default:
1059- if result.Text == "" {
1060- result.Text = "daemon did not report a final outcome"
1061- }
1062- return &exitCoded{code: 3, err: fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text)}
1063- }
1064-}
1065-
1066-// gracefulTeardownWaiter is implemented by carriages (QUIC) whose Close defers
1067-// a bounded connection close past its return. The broker mux QUIC proxy owns its
1068-// process, so it must wait for that close before returning: exiting first
1069-// would discard the final synchronous envelope Close already handed to the
1070-// stream.
1071-type gracefulTeardownWaiter interface {
1072- WaitGracefulTeardown(ctx context.Context) error
1073-}
1074-
1075-// proxyTeardownTimeout bounds the proxy's wait for a deferred graceful close,
1076-// leaving headroom beyond the adapter's own graceful window. A carriage that
1077-// does not defer its close is not waited on.
1078-const proxyTeardownTimeout = 2 * time.Second
1079-
1080-func waitGracefulTeardown(transport wire.Transport) error {
1081- waiter, ok := transport.(gracefulTeardownWaiter)
1082- if !ok {
1083- return nil
1084- }
1085- ctx, cancel := context.WithTimeout(context.Background(), proxyTeardownTimeout)
1086- defer cancel()
1087- return waiter.WaitGracefulTeardown(ctx)
1088-}
1089-
1090-// runList reads existing state without starting either background process.
1091-func runList(ctx context.Context, cmd command) error {
1092- service, err := connectListBroker(ctx)
1093- if errors.Is(err, errBrokerAbsent) {
1094- return runListWithoutBroker(ctx, cmd, os.Stdout)
1095- }
1096- if err != nil {
1097- return unreachableBrokerError(err)
1098- }
1099- defer func() { _ = service.Close() }()
1100- if cmd.listAll || cmd.listHost != "" {
1101- return runBrokerSnapshotList(cmd, service.Snapshot(), os.Stdout)
1102- }
1103- operations, err := client.NewBrokerOperations(service, clock.New())
1104- if err != nil {
1105- return err
1106- }
1107- sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot()))
1108- if err != nil {
1109- var brokerErr ports.BrokerError
1110- if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
1111- printSessions(os.Stdout, nil)
1112- return nil
1113- }
1114- return err
1115- }
1116- printSessions(os.Stdout, sessions)
1117- return nil
1118-}
1119-
1120-var connectListBroker = connectExistingBroker
1121-var dialListDaemon = ipc.DialContext
1122-
1123-// errNoLocalDaemon reports that no local daemon socket answers; nothing was
1124-// started to find out.
1125-var errNoLocalDaemon = errors.New("vev: no local daemon")
1126-
1127-// exchangeLocalDaemon sends one request directly to an existing local daemon
1128-// and returns its reply. It is the broker-less path of read and stop
1129-// commands: the broker being absent is the normal idle state, and these
1130-// commands must stay true without starting it. It never spawns a daemon.
1131-func exchangeLocalDaemon(ctx context.Context, request protocol.ClientMessage) (protocol.ServerMessage, error) {
1132- transport, err := dialListDaemon(ctx, ipc.SocketDir())
1133- if err != nil {
1134- if backendAbsent(err) {
1135- return nil, errNoLocalDaemon
1136- }
1137- return nil, err
1138- }
1139- connection := sessionwire.NewClientConnection(transport)
1140- defer func() { _ = connection.Close() }()
1141- if err := connection.SendClient(request); err != nil {
1142- return nil, err
1143- }
1144- return connection.ReceiveServer()
1145-}
1146-
1147-func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error {
1148- if cmd.listHost != "" {
1149- _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost)
1150- return err
1151- }
1152- reply, err := exchangeLocalDaemon(ctx, protocol.List{})
1153- switch {
1154- case errors.Is(err, errNoLocalDaemon):
1155- printSessions(out, nil)
1156- case err != nil:
1157- return fmt.Errorf("vev: list local sessions: %w", err)
1158- default:
1159- sessions, ok := reply.(protocol.Sessions)
1160- if !ok {
1161- return fmt.Errorf("vev: unexpected local list reply %T", reply)
1162- }
1163- printSessions(out, sessions.Sessions)
1164- }
1165- if cmd.listAll {
1166- return listDisconnectedHosts(out)
1167- }
1168- return nil
1169-}
1170-
1171200 // stopDaemonWithoutBroker asks an existing local daemon to stop directly. A
1172201 // daemon routinely outlives the idle broker, so "no broker" never means "no
1173202 // daemon".
@@ -1177,82 +206,11 @@ func stopDaemonWithoutBroker(ctx context.Context) error {
1177206 return errDaemonNotRunning
1178207 }
1179208 if err != nil {
1180- return fmt.Errorf("vev: stop local daemon: %w", err)
209+ return fmt.Errorf("stop local daemon: %w", err)
1181210 }
1182211 result, ok := reply.(protocol.KillResult)
1183212 if !ok {
1184- return fmt.Errorf("vev: unexpected daemon stop reply %T", reply)
213+ return fmt.Errorf("unexpected daemon stop reply %T", reply)
1185214 }
1186215 return brokerKillResultError(result)
1187216 }
1188-
1189-// printSessions renders a session table (or a friendly note when empty).
1190-func printSessions(w io.Writer, sessions []protocol.SessionInfo) {
1191- if len(sessions) == 0 {
1192- _, _ = fmt.Fprintln(w, "no sessions")
1193- return
1194- }
1195- tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
1196- _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
1197- for _, s := range sessions {
1198- state := "up"
1199- tabs := fmt.Sprintf("%d", s.Tabs)
1200- attached := "no"
1201- switch s.State {
1202- case protocol.SessionDown:
1203- state = "down"
1204- tabs = "-"
1205- case protocol.SessionBroken:
1206- state = "broken"
1207- tabs = "-"
1208- default:
1209- if s.Ephemeral {
1210- state = "temporary"
1211- }
1212- }
1213- if s.Attached {
1214- attached = "yes"
1215- }
1216- _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
1217- }
1218- _ = tw.Flush()
1219-}
1220-
1221-func unreadableCatalogueError(stateDir string) error {
1222- return fmt.Errorf("%w: vev: durable session state at %s cannot be read and was left untouched.\n"+
1223- "vev does not erase it automatically. To start fresh, remove it:\n"+
1224- " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir)
1225-}
1226-
1227-// runKill deletes one session or, with sessions set, every session through the
1228-// broker, starting it if needed.
1229-func runKill(ctx context.Context, name string, sessions bool) error {
1230- service, err := connectBroker(ctx)
1231- if err != nil {
1232- return unreachableBrokerError(err)
1233- }
1234- defer func() { _ = service.Close() }()
1235- operations, err := client.NewBrokerOperations(service, clock.New())
1236- if err != nil {
1237- return err
1238- }
1239- route := localBrokerOperationRoute(service.Snapshot())
1240- var result protocol.KillResult
1241- if sessions {
1242- result, err = operations.KillAll(ctx, route)
1243- } else {
1244- result, err = operations.Kill(ctx, route, name)
1245- }
1246- if err != nil {
1247- return err
1248- }
1249- if err := brokerKillResultError(result); err != nil {
1250- return err
1251- }
1252- if sessions {
1253- fmt.Println("killed all sessions")
1254- } else {
1255- fmt.Printf("killed %s\n", name)
1256- }
1257- return nil
1258-}
M internal/app/run_broker_test.go +1 -1
@@ -474,7 +474,7 @@ func TestCreateDetachedLocalSessionUsesBrokerOperations(t *testing.T) {
474474 // not reappear in its body as a fallback.
475475 func TestDetachedCreationProductionSourceHasNoDaemonBypass(t *testing.T) {
476476 sources := loadAppSources(t, false)
477- body := sources["run.go"]
477+ body := sources["sessions.go"]
478478 start := strings.Index(body, "func createDetachedLocalSession")
479479 require.NotEqual(t, -1, start)
480480 end := strings.Index(body[start:], "\nfunc renderDetachedCreationResult")
M internal/app/run_test.go +7 -0
@@ -462,6 +462,13 @@ func TestParseArgs(t *testing.T) {
462462 {name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},
463463 {name: "kill without name", args: []string{"kill"}, wantErr: true},
464464 {name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
465+ {name: "kill terminator empty name", args: []string{"kill", "--", ""}, wantErr: true},
466+ {name: "leading ui socket without value", args: []string{"--ui-socket"}, wantErr: true},
467+ {name: "leading ui flag before ls", args: []string{"--ui-observe", "ls"}, wantErr: true},
468+ {name: "leading ui flag before ui-driver", args: []string{"--ui-observe", "--ui-driver"}, wantErr: true},
469+ {name: "leading ui flag only", args: []string{"--ui-observe"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
470+ {name: "new remote with ui flag", args: []string{"new", "work", "user@example.com", "--ui-control"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"},
471+ {name: "web renew rejects args", args: []string{"--web-renew-token", "x"}, wantErr: true},
465472 {name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
466473 {name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
467474 {name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
A internal/app/sessions.go +217 -0
@@ -0,0 +1,217 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "text/tabwriter"
10+
11+ "github.com/bnema/vev/internal/adapters/clock"
12+ "github.com/bnema/vev/internal/adapters/ipc"
13+ "github.com/bnema/vev/internal/adapters/sessionwire"
14+ "github.com/bnema/vev/internal/persist"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// Broker connection seams replaced by tests.
21+var (
22+ connectBroker = connectProductionBroker
23+ connectListBroker = connectExistingBroker
24+ dialListDaemon = ipc.DialContext
25+)
26+
27+var errKillOutcomeUnknown = errors.New("kill outcome unknown")
28+
29+// errNoLocalDaemon reports that no local daemon socket answers; nothing was
30+// started to find out.
31+var errNoLocalDaemon = errors.New("no local daemon")
32+
33+func createDetachedLocalSession(ctx context.Context, name string) error {
34+ service, err := connectBroker(ctx)
35+ if err != nil {
36+ return fmt.Errorf("%w: %v", errDaemonUnreachable, err)
37+ }
38+ defer func() { _ = service.Close() }()
39+ operations, err := client.NewBrokerOperations(service, clock.New())
40+ if err != nil {
41+ return err
42+ }
43+ identity, _ := parseVEVEnv(os.Getenv("VEV"))
44+ result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), protocol.CommandRequest{
45+ Version: protocol.Version, Slug: "new-session", Args: []string{name}, TargetSession: identity.session,
46+ })
47+ if err != nil {
48+ return err
49+ }
50+ return renderDetachedCreationResult(result)
51+}
52+
53+func renderDetachedCreationResult(result protocol.CommandResult) error {
54+ return renderCommandResult(io.Discard, result)
55+}
56+
57+func brokerKillResultError(result protocol.KillResult) error {
58+ switch result.Outcome {
59+ case protocol.KillSucceeded:
60+ return nil
61+ case protocol.KillFailed:
62+ if result.Text == "" {
63+ result.Text = "kill failed"
64+ }
65+ return errors.New(result.Text)
66+ default:
67+ if result.Text == "" {
68+ result.Text = "daemon did not report a final outcome"
69+ }
70+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %s", errKillOutcomeUnknown, result.Text)}
71+ }
72+}
73+
74+// runList reads existing state without starting either background process.
75+func runList(ctx context.Context, cmd command) error {
76+ service, err := connectListBroker(ctx)
77+ if errors.Is(err, errBrokerAbsent) {
78+ return runListWithoutBroker(ctx, cmd, os.Stdout)
79+ }
80+ if err != nil {
81+ return unreachableBrokerError(err)
82+ }
83+ defer func() { _ = service.Close() }()
84+ if cmd.listAll || cmd.listHost != "" {
85+ return runBrokerSnapshotList(cmd, service.Snapshot(), os.Stdout)
86+ }
87+ operations, err := client.NewBrokerOperations(service, clock.New())
88+ if err != nil {
89+ return err
90+ }
91+ sessions, err := operations.List(ctx, localBrokerOperationRoute(service.Snapshot()))
92+ if err != nil {
93+ var brokerErr ports.BrokerError
94+ if errors.As(err, &brokerErr) && brokerErr.Code == ports.BrokerErrorNoDaemon {
95+ printSessions(os.Stdout, nil)
96+ return nil
97+ }
98+ return err
99+ }
100+ printSessions(os.Stdout, sessions)
101+ return nil
102+}
103+
104+// exchangeLocalDaemon sends one request directly to an existing local daemon
105+// and returns its reply. It is the broker-less path of read and stop
106+// commands: the broker being absent is the normal idle state, and these
107+// commands must stay true without starting it. It never spawns a daemon.
108+func exchangeLocalDaemon(ctx context.Context, request protocol.ClientMessage) (protocol.ServerMessage, error) {
109+ transport, err := dialListDaemon(ctx, ipc.SocketDir())
110+ if err != nil {
111+ if backendAbsent(err) {
112+ return nil, errNoLocalDaemon
113+ }
114+ return nil, err
115+ }
116+ connection := sessionwire.NewClientConnection(transport)
117+ defer func() { _ = connection.Close() }()
118+ if err := connection.SendClient(request); err != nil {
119+ return nil, err
120+ }
121+ return connection.ReceiveServer()
122+}
123+
124+func runListWithoutBroker(ctx context.Context, cmd command, out io.Writer) error {
125+ if cmd.listHost != "" {
126+ _, err := fmt.Fprintf(out, "%s: not connected (no broker running)\n", cmd.listHost)
127+ return err
128+ }
129+ reply, err := exchangeLocalDaemon(ctx, protocol.List{})
130+ switch {
131+ case errors.Is(err, errNoLocalDaemon):
132+ printSessions(out, nil)
133+ case err != nil:
134+ return fmt.Errorf("list local sessions: %w", err)
135+ default:
136+ sessions, ok := reply.(protocol.Sessions)
137+ if !ok {
138+ return fmt.Errorf("unexpected local list reply %T", reply)
139+ }
140+ printSessions(out, sessions.Sessions)
141+ }
142+ if cmd.listAll {
143+ return listDisconnectedHosts(out)
144+ }
145+ return nil
146+}
147+
148+// printSessions renders a session table (or a friendly note when empty).
149+func printSessions(w io.Writer, sessions []protocol.SessionInfo) {
150+ if len(sessions) == 0 {
151+ _, _ = fmt.Fprintln(w, "no sessions")
152+ return
153+ }
154+ tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
155+ _, _ = fmt.Fprintln(tw, "NAME\tSTATE\tTABS\tATTACHED")
156+ for _, s := range sessions {
157+ state := "up"
158+ tabs := fmt.Sprintf("%d", s.Tabs)
159+ attached := "no"
160+ switch s.State {
161+ case protocol.SessionDown:
162+ state = "down"
163+ tabs = "-"
164+ case protocol.SessionBroken:
165+ state = "broken"
166+ tabs = "-"
167+ default:
168+ if s.Ephemeral {
169+ state = "temporary"
170+ }
171+ }
172+ if s.Attached {
173+ attached = "yes"
174+ }
175+ _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, state, tabs, attached)
176+ }
177+ _ = tw.Flush()
178+}
179+
180+func unreadableCatalogueError(stateDir string) error {
181+ return fmt.Errorf("%w: durable session state at %s cannot be read and was left untouched.\n"+
182+ "vev does not erase it automatically. To start fresh, remove it:\n"+
183+ " rm -rf %s", persist.ErrCatalogueUnreadable, stateDir, stateDir)
184+}
185+
186+// runKill deletes one session or, with sessions set, every session through the
187+// broker, starting it if needed.
188+func runKill(ctx context.Context, name string, sessions bool) error {
189+ service, err := connectBroker(ctx)
190+ if err != nil {
191+ return unreachableBrokerError(err)
192+ }
193+ defer func() { _ = service.Close() }()
194+ operations, err := client.NewBrokerOperations(service, clock.New())
195+ if err != nil {
196+ return err
197+ }
198+ route := localBrokerOperationRoute(service.Snapshot())
199+ var result protocol.KillResult
200+ if sessions {
201+ result, err = operations.KillAll(ctx, route)
202+ } else {
203+ result, err = operations.Kill(ctx, route, name)
204+ }
205+ if err != nil {
206+ return err
207+ }
208+ if err := brokerKillResultError(result); err != nil {
209+ return err
210+ }
211+ if sessions {
212+ fmt.Println("killed all sessions")
213+ } else {
214+ fmt.Printf("killed %s\n", name)
215+ }
216+ return nil
217+}
M internal/app/spawn.go +5 -5
@@ -23,12 +23,12 @@ import (
2323
2424 // ErrDaemonUnreachable is returned when the daemon socket never becomes
2525 // dialable within the retry budget, despite a spawn attempt.
26-var ErrDaemonUnreachable = errors.New("vev: daemon did not become reachable")
26+var ErrDaemonUnreachable = errors.New("daemon did not become reachable")
2727
2828 // ErrLifecycleHeldByOther reports that another process kept the daemon
2929 // lifecycle lock for the whole start budget without publishing the carriage,
3030 // for example a vev daemon from an older build. Spawning cannot recover this.
31-var ErrLifecycleHeldByOther = errors.New("vev: a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry")
31+var ErrLifecycleHeldByOther = errors.New("a vev daemon holds the lifecycle lock but did not answer within the start budget; if it is from an older build, run `vev kill --all` and retry")
3232
3333 // spawnLockName is the mkdir-based lock directory guarding daemon spawn, so
3434 // concurrent first-clients elect a single spawner instead of racing to
@@ -195,7 +195,7 @@ func ensureTargetWithLifecycle[T any](ctx context.Context, lockDir string, dial
195195 return target, nil
196196 }
197197 if err := owner.Release(); err != nil {
198- return zero, fmt.Errorf("vev: release lifecycle spawn probe: %w", err)
198+ return zero, fmt.Errorf("release lifecycle spawn probe: %w", err)
199199 }
200200 return ensureTarget(ctx, lockDir, dial, spawn, cfg)
201201 }
@@ -216,7 +216,7 @@ func ensureTarget[T any](ctx context.Context, lockDir string, dial func(ctx cont
216216
217217 release, acquired, err := acquireSpawnLock(lockDir)
218218 if err != nil {
219- return zero, fmt.Errorf("vev: acquiring spawn lock: %w", err)
219+ return zero, fmt.Errorf("acquiring spawn lock: %w", err)
220220 }
221221 if acquired {
222222 // We won the election: spawn, and hold the lock until the socket is
@@ -229,7 +229,7 @@ func ensureTarget[T any](ctx context.Context, lockDir string, dial func(ctx cont
229229 }
230230 if err := spawn(); err != nil {
231231 slog.Error("daemon spawn failed", "err", err)
232- return zero, fmt.Errorf("vev: spawning daemon: %w", err)
232+ return zero, fmt.Errorf("spawning daemon: %w", err)
233233 }
234234 } else {
235235 slog.Debug("waiting for daemon spawned by another process", "socket_dir", lockDir)
A internal/app/trace.go +107 -0
@@ -0,0 +1,107 @@
1+package app
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "io"
7+ "os"
8+ "strconv"
9+ "sync"
10+
11+ "github.com/bnema/vev/internal/adapters/observability"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+// newPerformanceTrace is the trace factory seam; tests replace it to observe
16+// propagation without opening real trace files.
17+var newPerformanceTrace = performanceTrace
18+
19+// performanceTrace creates one serialized timestamp owner for this process.
20+// An empty trace environment leaves all production behavior and wire bytes
21+// unchanged.
22+func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
23+ return performanceTraceWithFactories(clk, observability.NewJSONL, ports.NewRuntimeCorrelationObserver)
24+}
25+
26+// performanceTraceWithFactories keeps setup rollback behavior directly
27+// testable without requiring an operating-system file close failure.
28+func performanceTraceWithFactories(
29+ clk ports.Clock,
30+ newSink func(string, ports.Clock, string) (ports.RuntimeObserver, io.Closer, error),
31+ newCorrelation func(ports.RuntimeObserver, ports.RuntimeCorrelationInputs) (ports.RuntimeObserver, error),
32+) (ports.SerializedRuntimeObserver, io.Closer, error) {
33+ path, processID := os.Getenv("VEV_PERF_TRACE"), os.Getenv("VEV_PERF_PROCESS_ID")
34+ if path == "" {
35+ return nil, nil, nil
36+ }
37+ observer, closer, err := newSink(path, clk, processID)
38+ if err != nil {
39+ return nil, nil, err
40+ }
41+ // The harness supplies these manifest fields to every launched role. Keep a
42+ // valid standalone trace for operators that set only the original trace
43+ // variables, while ensuring harness traces match their process mapping.
44+ inputs := ports.RuntimeCorrelationInputs{Scenario: os.Getenv("VEV_PERF_SCENARIO"), Run: 1}
45+ if inputs.Scenario == "" {
46+ inputs.Scenario = "runtime"
47+ }
48+ if rawRun := os.Getenv("VEV_PERF_RUN"); rawRun != "" {
49+ inputs.Run, err = strconv.ParseUint(rawRun, 10, 64)
50+ if err != nil {
51+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q: %w", rawRun, err)
52+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
53+ }
54+ if inputs.Run == 0 {
55+ setupErr := fmt.Errorf("invalid VEV_PERF_RUN %q", rawRun)
56+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
57+ }
58+ }
59+ observer, err = newCorrelation(observer, inputs)
60+ if err != nil {
61+ setupErr := fmt.Errorf("configure runtime trace correlation: %w", err)
62+ return nil, nil, closeTraceAfterSetupFailure(closer, setupErr)
63+ }
64+ reporter := observability.NewSerialized(observer, runtimeTraceQueueDepth)
65+ return reporter, &runtimeTraceCloser{reporter: reporter, closer: closer}, nil
66+}
67+
68+func closeTraceAfterSetupFailure(closer io.Closer, setupErr error) error {
69+ if closer == nil {
70+ return setupErr
71+ }
72+ if closeErr := closer.Close(); closeErr != nil {
73+ return errors.Join(setupErr, fmt.Errorf("close performance trace after setup failure: %w", closeErr))
74+ }
75+ return setupErr
76+}
77+
78+// runtimeTraceQueueDepth bounds all process-local trace producer handoffs.
79+// A full queue emits the serialized diagnostic gap rather than delaying a
80+// terminal, transport, or ACK progress path.
81+const runtimeTraceQueueDepth = 256
82+
83+// runtimeTraceCloser owns the two-stage trace shutdown: drain the reporter
84+// before closing the concrete timestamp/file owner. sync.Once makes every
85+// deferred and explicit close path safe without closing shared workers twice.
86+type runtimeTraceCloser struct {
87+ reporter ports.SerializedRuntimeObserver
88+ closer io.Closer
89+ once sync.Once
90+ err error
91+}
92+
93+func (c *runtimeTraceCloser) Close() error {
94+ if c == nil {
95+ return nil
96+ }
97+ c.once.Do(func() {
98+ if c.reporter != nil {
99+ c.reporter.Flush()
100+ c.reporter.Close()
101+ }
102+ if c.closer != nil {
103+ c.err = c.closer.Close()
104+ }
105+ })
106+ return c.err
107+}
M internal/app/ui_driver.go +10 -103
@@ -13,8 +13,6 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/adapters/clock"
16- "github.com/bnema/vev/internal/adapters/ipc"
17- "github.com/bnema/vev/internal/adapters/term"
1816 "github.com/bnema/vev/internal/adapters/uidriver"
1917 "github.com/bnema/vev/internal/adapters/uiterm"
2018 "github.com/bnema/vev/internal/domain"
@@ -50,8 +48,12 @@ type interactiveUIOptions struct {
5048 socket string
5149 }
5250
53-func parseInteractiveUIFlags(args []string, observe, control bool, socket string) (interactiveUIOptions, error) {
54- options := interactiveUIOptions{observe: observe, control: control, socket: socket}
51+func (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.
56+func parseInteractiveUIFlags(args []string, options interactiveUIOptions) (interactiveUIOptions, error) {
5557 for len(args) > 0 {
5658 switch args[0] {
5759 case "--ui-observe":
@@ -235,7 +237,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
235237 defer func() { _ = logCloser.Close() }()
236238 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: options.cols, Rows: options.rows}}, "")
237239 if err != nil {
238- return fmt.Errorf("vev: create headless terminal: %w", err)
240+ return fmt.Errorf("create headless terminal: %w", err)
239241 }
240242 defer terminal.Close()
241243 ui := client.NewUI(terminal, clk)
@@ -270,10 +272,10 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
270272 // daemon, and every session stay alive for the other clients.
271273 func runUIDriverClient(ctx context.Context, cfg brokerClientConfig, stream io.ReadWriteCloser) error {
272274 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")
275+ return errors.New("UI driver requires a UI, a terminal, and a clock")
274276 }
275277 if stream == nil {
276- return errors.New("vev: UI driver requires a stream")
278+ return errors.New("UI driver requires a stream")
277279 }
278280 runCtx, cancel := context.WithCancel(ctx)
279281 defer cancel()
@@ -325,7 +327,7 @@ func waitForUIDriverPublication(ctx context.Context, ui *client.UI) (ports.UISna
325327 })
326328 if err != nil {
327329 if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
328- return ports.UISnapshot{}, fmt.Errorf("vev: UI driver published no initial state: %w", err)
330+ return ports.UISnapshot{}, fmt.Errorf("UI driver published no initial state: %w", err)
329331 }
330332 return ports.UISnapshot{}, err
331333 }
@@ -414,98 +416,3 @@ func (s *stdioStream) Close() error {
414416 })
415417 return closeErr
416418 }
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.
427-func 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-}
M internal/app/ui_driver_test.go +5 -5
@@ -85,8 +85,8 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) {
8585 command, err := parseArgs([]string{"--ui-control", "new", "work"})
8686 require.NoError(t, err)
8787 require.Equal(t, kindAttach, command.kind)
88- require.True(t, command.uiControl)
89- require.False(t, command.uiObserve)
88+ require.True(t, command.ui.control)
89+ require.False(t, command.ui.observe)
9090 driver, err := parseArgs([]string{"--ui-driver", "--session", "work"})
9191 require.NoError(t, err)
9292 require.Equal(t, kindUIDriver, driver.kind)
@@ -101,8 +101,8 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) {
101101 require.Error(t, err)
102102 trailing, err := parseArgs([]string{"attach", "work", "--ui-observe", "--ui-socket", "/tmp/ui.sock"})
103103 require.NoError(t, err)
104- require.True(t, trailing.uiObserve)
105- require.Equal(t, "/tmp/ui.sock", trailing.uiSocket)
104+ require.True(t, trailing.ui.observe)
105+ require.Equal(t, "/tmp/ui.sock", trailing.ui.socket)
106106 _, err = parseArgs([]string{"--ui-observe", "ls"})
107107 require.Error(t, err)
108108 _, err = parseArgs([]string{"--ui-socket", "/tmp/ui.sock"})
@@ -115,7 +115,7 @@ func TestParseArgsUIOptionsAreAttachOnly(t *testing.T) {
115115 // client over the process' production connector.
116116 func TestInteractiveObservedCompositionUsesTheBrokerConnector(t *testing.T) {
117117 sources := loadAppSources(t, false)
118- body := sources["ui_driver.go"]
118+ body := sources["ui_driver.go"] + sources["attach.go"]
119119 require.Contains(t, body, "newProductionBrokerConnector()")
120120 for _, forbidden := range []string{
121121 "runAttachWithDeps", "localDaemonDialer", "remoteDialerFactory", "launchConfig",
M internal/app/web_control.go +4 -4
@@ -70,10 +70,10 @@ func webControlRequest(ctx context.Context, renew bool) (webAccess, error) {
7070 defer conn.Close()
7171 unixConn, ok := conn.(*net.UnixConn)
7272 if !ok {
73- return webAccess{}, errors.New("vev: unsafe web control peer")
73+ return webAccess{}, errors.New("unsafe web control peer")
7474 }
7575 if !sameWebUID(unixConn) {
76- return webAccess{}, errors.New("vev: unsafe web control peer")
76+ return webAccess{}, errors.New("unsafe web control peer")
7777 }
7878 deadline, _ := ctx.Deadline()
7979 _ = conn.SetDeadline(deadline)
@@ -90,12 +90,12 @@ func webControlRequest(ctx context.Context, renew bool) (webAccess, error) {
9090 }
9191 var access webAccess
9292 if len(data) > 4096 || json.Unmarshal(data, &access) != nil {
93- return webAccess{}, errors.New("vev: invalid web control response")
93+ return webAccess{}, errors.New("invalid web control response")
9494 }
9595 decoded, err := base64.RawURLEncoding.DecodeString(access.Token)
9696 settings, settingsErr := webterm.ParseSettings(access.Settings.Listen, access.Settings.Origin)
9797 if err != nil || len(decoded) != 32 || settingsErr != nil || settings != access.Settings {
98- return webAccess{}, errors.New("vev: invalid web control response")
98+ return webAccess{}, errors.New("invalid web control response")
9999 }
100100 return access, nil
101101 }
M internal/app/web_control_darwin.go +4 -4
@@ -44,7 +44,7 @@ func sameWebUID(conn *net.UnixConn) bool {
4444 func bindWebControl() (*net.UnixListener, error) {
4545 path := webControlAddress()
4646 if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
47- return nil, fmt.Errorf("vev: securing web control directory: %w", err)
47+ return nil, fmt.Errorf("securing web control directory: %w", err)
4848 }
4949 listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
5050 if err != nil {
@@ -55,10 +55,10 @@ func bindWebControl() (*net.UnixListener, error) {
5555 probeConn, probeErr := net.DialTimeout("unix", path, time.Second)
5656 if probeErr == nil {
5757 _ = probeConn.Close()
58- return nil, errors.New("vev: web gateway control is already bound")
58+ return nil, errors.New("web gateway control is already bound")
5959 }
6060 if !errors.Is(probeErr, syscall.ECONNREFUSED) && !errors.Is(probeErr, syscall.ENOENT) {
61- return nil, fmt.Errorf("vev: probing web control socket: %w", probeErr)
61+ return nil, fmt.Errorf("probing web control socket: %w", probeErr)
6262 }
6363 if rmErr := os.Remove(path); rmErr != nil && !os.IsNotExist(rmErr) {
6464 return nil, rmErr
@@ -74,7 +74,7 @@ func bindWebControl() (*net.UnixListener, error) {
7474 func secureWebControlSocket(listener *net.UnixListener, path string) error {
7575 if err := os.Chmod(path, 0o600); err != nil {
7676 _ = listener.Close()
77- return fmt.Errorf("vev: securing web control socket: %w", err)
77+ return fmt.Errorf("securing web control socket: %w", err)
7878 }
7979 return nil
8080 }
M internal/app/web_daemon.go +12 -12
@@ -47,7 +47,7 @@ func printWebLink(access webAccess) {
4747 func renewWebToken(ctx context.Context) error {
4848 token, err := webControlRequest(ctx, true)
4949 if err != nil {
50- return fmt.Errorf("vev: renewing web token: %w", err)
50+ return fmt.Errorf("renewing web token: %w", err)
5151 }
5252 printWebLink(token)
5353 return nil
@@ -71,7 +71,7 @@ func launchWebDaemon(ctx context.Context, options webOptions) error {
7171 defer client.CloseIdleConnections()
7272 if token, err := webControlRequest(ctx, false); err == nil {
7373 if token.Settings != settings {
74- return errors.New("vev: web gateway is running with different settings; stop and restart it to apply web.listen/web.origin")
74+ return errors.New("web gateway is running with different settings; stop and restart it to apply web.listen/web.origin")
7575 }
7676 readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout)
7777 defer cancel()
@@ -80,7 +80,7 @@ func launchWebDaemon(ctx context.Context, options webOptions) error {
8080 for !webReachable(readyCtx, client, token, settings) {
8181 select {
8282 case <-readyCtx.Done():
83- return fmt.Errorf("vev: existing web gateway not ready: %w", readyCtx.Err())
83+ return fmt.Errorf("existing web gateway not ready: %w", readyCtx.Err())
8484 case <-ticker.C:
8585 }
8686 }
@@ -89,12 +89,12 @@ func launchWebDaemon(ctx context.Context, options webOptions) error {
8989 }
9090 listener, err := listenWeb(settings.Listen)
9191 if err != nil {
92- return fmt.Errorf("vev: web port unavailable: %w", err)
92+ return fmt.Errorf("web port unavailable: %w", err)
9393 }
9494 defer listener.Close()
9595 inherited, ok := listener.(*net.TCPListener)
9696 if !ok {
97- return fmt.Errorf("vev: web listener is not TCP")
97+ return fmt.Errorf("web listener is not TCP")
9898 }
9999 inheritedFile, err := inherited.File()
100100 if err != nil {
@@ -134,13 +134,13 @@ func launchWebDaemon(ctx context.Context, options webOptions) error {
134134 select {
135135 case err := <-done:
136136 if err != nil {
137- return fmt.Errorf("vev: web process exited before readiness: %w", err)
137+ return fmt.Errorf("web process exited before readiness: %w", err)
138138 }
139- return errors.New("vev: web process exited before readiness")
139+ return errors.New("web process exited before readiness")
140140 case <-readyCtx.Done():
141141 _ = child.Process.Kill()
142142 <-done
143- return fmt.Errorf("vev: web startup: %w", readyCtx.Err())
143+ return fmt.Errorf("web startup: %w", readyCtx.Err())
144144 case <-ticker.C:
145145 }
146146 }
@@ -225,7 +225,7 @@ func listenWeb(address string) (net.Listener, error) {
225225 func runWebDaemon(parent context.Context, options webOptions) error {
226226 // The launcher passes resolved settings; do not reload a changing config.
227227 if options.listen == "" || options.origin == "" {
228- return errors.New("vev: web server requires its launcher settings")
228+ return errors.New("web server requires its launcher settings")
229229 }
230230 settings, err := webterm.ParseSettings(options.listen, options.origin)
231231 if err != nil {
@@ -235,16 +235,16 @@ func runWebDaemon(parent context.Context, options webOptions) error {
235235 defer cancel()
236236 inherited := os.NewFile(3, "web-listener")
237237 if inherited == nil {
238- return errors.New("vev: web server requires its launcher")
238+ return errors.New("web server requires its launcher")
239239 }
240240 listener, err := net.FileListener(inherited)
241241 _ = inherited.Close()
242242 if err != nil {
243- return fmt.Errorf("vev: inherited web listener: %w", err)
243+ return fmt.Errorf("inherited web listener: %w", err)
244244 }
245245 defer listener.Close()
246246 if listener.Addr().String() != settings.Listen {
247- return errors.New("vev: inherited web listener does not match web.listen")
247+ return errors.New("inherited web listener does not match web.listen")
248248 }
249249 token, err := webterm.NewToken()
250250 if err != nil {
M internal/app/web_options.go +1 -1
@@ -47,7 +47,7 @@ func parseWebArgs(args []string) (command, error) {
4747 func loadWebSettings(options webOptions) (webterm.Settings, []domain.Warning, error) {
4848 cfg, warnings, err := config.Load(platform.ConfigPath())
4949 if err != nil {
50- return webterm.Settings{}, nil, fmt.Errorf("vev: loading web configuration: %w", err)
50+ return webterm.Settings{}, nil, fmt.Errorf("loading web configuration: %w", err)
5151 }
5252 if options.listen != "" {
5353 cfg.WebListen = options.listen
M internal/platform/dir.go +4 -4
@@ -17,18 +17,18 @@ func ActivateDevelopmentEnvironment() error {
1717 return nil
1818 }
1919 if !validDevelopmentEnvironmentName(name) {
20- return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name)
20+ return fmt.Errorf("invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name)
2121 }
2222
2323 base := os.Getenv(devEnvironmentRootEnv)
2424 if base == "" {
2525 absolute, err := filepath.Abs(".dev")
2626 if err != nil {
27- return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err)
27+ return fmt.Errorf("resolve VEV_ENV %q root: %w", name, err)
2828 }
2929 base = absolute
3030 } else if !filepath.IsAbs(base) {
31- return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base)
31+ return fmt.Errorf("invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base)
3232 }
3333 root := filepath.Join(base, name)
3434
@@ -56,7 +56,7 @@ func ActivateDevelopmentEnvironment() error {
5656 _ = os.Unsetenv(rollbackKey)
5757 }
5858 }
59- return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err)
59+ return fmt.Errorf("activate VEV_ENV %q: %w", name, err)
6060 }
6161 }
6262 return nil
M internal/ports/broker.go +3 -3
@@ -638,9 +638,9 @@ type BrokerError struct {
638638
639639 func (e BrokerError) Error() string {
640640 if e.Text == "" {
641- return "vev: broker " + e.Code.String()
641+ return "broker " + e.Code.String()
642642 }
643- return "vev: broker " + e.Code.String() + ": " + e.Text
643+ return "broker " + e.Code.String() + ": " + e.Text
644644 }
645645
646646 func (e BrokerError) Unwrap() error { return e.Cause }
@@ -670,7 +670,7 @@ type BrokerStreamLost struct {
670670 }
671671
672672 // Error and Unwrap retain exact stream scope while allowing generic lifecycle handling.
673-func (e BrokerStreamLost) Error() string { return "vev: broker attachment_lost" }
673+func (e BrokerStreamLost) Error() string { return "broker attachment_lost" }
674674 func (e BrokerStreamLost) Unwrap() error {
675675 return BrokerError{Code: BrokerErrorAttachmentLost, Cause: e.Err}
676676 }
M internal/ports/broker_pool.go +1 -1
@@ -35,7 +35,7 @@ type BrokerLogicalConnection interface {
3535 // ID; callers must use a fresh ID. A connection ID is never reused in an epoch.
3636 type BrokerAdmissionError string
3737
38-func (e BrokerAdmissionError) Error() string { return "vev: broker admission: " + string(e) }
38+func (e BrokerAdmissionError) Error() string { return "broker admission: " + string(e) }
3939
4040 const (
4141 BrokerAdmissionLimit BrokerAdmissionError = "limit"
M internal/ports/broker_pool_test.go +1 -1
@@ -59,7 +59,7 @@ func TestBrokerStreamLostErrorChain(t *testing.T) {
5959 cause := errors.New("transport diagnostic")
6060 loss := BrokerStreamLost{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Cause: domain.RemoteFailureTrust, Err: cause}
6161 require.NoError(t, loss.Validate())
62- require.Equal(t, "vev: broker attachment_lost", loss.Error())
62+ require.Equal(t, "broker attachment_lost", loss.Error())
6363 require.ErrorIs(t, loss, cause)
6464 var typed BrokerError
6565 require.ErrorAs(t, loss, &typed)
M internal/protocol/preview.go +1 -1
@@ -49,7 +49,7 @@ const (
4949 RemotePreviewWatchMaxInterval = time.Second
5050 )
5151
52-var ErrInvalidRemotePreviewWatch = errors.New("vev: invalid remote preview watch")
52+var ErrInvalidRemotePreviewWatch = errors.New("invalid remote preview watch")
5353
5454 // ValidateRemotePreviewWatch accepts only a valid request and a whole
5555 // millisecond interval inside the watch bounds.
M internal/usecase/broker/daemon_start.go +1 -1
@@ -70,6 +70,6 @@ func AuthorizeDaemonLaunch(target ports.BrokerDialTarget) error {
7070 func daemonStartRefused(reason string) error {
7171 return ports.BrokerError{
7272 Code: ports.BrokerErrorConflictingPolicy,
73- Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
73+ Cause: fmt.Errorf("refusing to start the daemon: %s", reason),
7474 }
7575 }
M internal/usecase/broker/observation.go +18 -18
@@ -81,7 +81,7 @@ func (p *RemoteProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) }
8181
8282 func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
8383 if p == nil || p.Epoch == 0 || p.Routes == nil || p.Binder == nil || p.Connector == nil {
84- return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")
84+ return ports.BrokerDaemonObservation{}, errors.New("incomplete broker remote probe")
8585 }
8686 request, err := p.request(ctx, registration)
8787 if err != nil {
@@ -117,11 +117,11 @@ func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegis
117117 }
118118 }()
119119 if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy {
120- return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")
120+ return ports.BrokerDaemonObservation{}, errors.New("remote probe authenticated binding mismatch")
121121 }
122122 identity, err := p.Binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()})
123123 if err != nil || identity != physical.Identity() {
124- return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding")
124+ return ports.BrokerDaemonObservation{}, errors.New("remote probe could not commit authenticated binding")
125125 }
126126 observation, err := observeDaemonCatalogue(ctx, physical, request, p.Codec)
127127 if err == nil && ctx.Err() == nil {
@@ -132,7 +132,7 @@ func (p *RemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegis
132132
133133 func (p *RemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {
134134 if p.Hosts == nil {
135- return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")
135+ return ports.BrokerOpenStreamRequest{}, errors.New("remote probe has no policy authority")
136136 }
137137 record, ok, err := p.Hosts.LookupHost(ctx, registration.Endpoint)
138138 if err != nil {
@@ -175,22 +175,22 @@ var _ LocalProbe = (*LocalDaemonProbe)(nil)
175175 // probing must never spawn a process.
176176 func NewLocalDaemonProbe(epoch ports.BrokerEpoch, address string, policy ports.BrokerPolicy, connector ports.BrokerEndpointConnector, codec ports.SessionCodec, loadIdentity LocalIdentityLoader) (*LocalDaemonProbe, error) {
177177 if epoch == 0 {
178- return nil, errors.New("vev: broker local probe requires a broker epoch")
178+ return nil, errors.New("broker local probe requires a broker epoch")
179179 }
180180 if nilDependency(connector) {
181- return nil, errors.New("vev: broker local probe requires a connector")
181+ return nil, errors.New("broker local probe requires a connector")
182182 }
183183 if nilDependency(codec) {
184- return nil, errors.New("vev: broker local probe requires a codec")
184+ return nil, errors.New("broker local probe requires a codec")
185185 }
186186 if loadIdentity == nil {
187- return nil, errors.New("vev: broker local probe requires an identity loader")
187+ return nil, errors.New("broker local probe requires an identity loader")
188188 }
189189 if address == "" {
190- return nil, errors.New("vev: broker local probe requires an address")
190+ return nil, errors.New("broker local probe requires an address")
191191 }
192192 if err := policy.Validate(); err != nil {
193- return nil, fmt.Errorf("vev: broker local probe: %w", err)
193+ return nil, fmt.Errorf("broker local probe: %w", err)
194194 }
195195 return &LocalDaemonProbe{epoch: epoch, address: address, policy: policy, loadIdentity: loadIdentity, connector: connector, codec: codec}, nil
196196 }
@@ -207,7 +207,7 @@ func (p *LocalDaemonProbe) SharePool(pool *Pool) { p.shared.pool.Store(pool) }
207207 func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
208208 unreachable := ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}
209209 if p == nil || p.connector == nil {
210- return unreachable, errors.New("vev: broker local probe is nil")
210+ return unreachable, errors.New("broker local probe is nil")
211211 }
212212 identity, err := p.loadIdentity()
213213 if err != nil {
@@ -222,7 +222,7 @@ func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonOb
222222 ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true},
223223 }
224224 if err := endpoint.Validate(); err != nil {
225- return unreachable, fmt.Errorf("vev: broker local probe: %w", err)
225+ return unreachable, fmt.Errorf("broker local probe: %w", err)
226226 }
227227 request, err := p.request()
228228 if err != nil {
@@ -243,7 +243,7 @@ func (p *LocalDaemonProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonOb
243243 // though the connector already checked it, so a future connector change can
244244 // never weaken what the probe publishes.
245245 if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) {
246- return unreachable, errors.New("vev: broker local probe authenticated binding mismatch")
246+ return unreachable, errors.New("broker local probe authenticated binding mismatch")
247247 }
248248 observation, err := observeDaemonCatalogue(ctx, physical, request, p.codec)
249249 if err != nil {
@@ -294,7 +294,7 @@ func newObservationRequest(epoch ports.BrokerEpoch, policy ports.BrokerPolicy) (
294294 // the caller receives an error and no observation it could publish.
295295 func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest, codec ports.SessionCodec) (ports.BrokerDaemonObservation, error) {
296296 if physical == nil || codec == nil || request.Purpose != ports.BrokerStreamObservation {
297- return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request")
297+ return ports.BrokerDaemonObservation{}, errors.New("invalid daemon observation request")
298298 }
299299 logical, err := physical.OpenStream(ctx, request)
300300 if err != nil {
@@ -305,7 +305,7 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo
305305 return ports.BrokerDaemonObservation{}, err
306306 }
307307 if logical == nil {
308- return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream")
308+ return ports.BrokerDaemonObservation{}, errors.New("daemon observation opened no logical stream")
309309 }
310310 defer func() { _ = logical.Close() }()
311311 commandID, err := randomNonzeroUint64()
@@ -318,14 +318,14 @@ func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalCo
318318 }
319319 result, ok := reply.(protocol.CommandResult)
320320 if !ok || result.RequestID != commandID || !result.Valid() {
321- return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result")
321+ return ports.BrokerDaemonObservation{}, errors.New("invalid daemon observation result")
322322 }
323323 if result.Outcome != protocol.CommandSucceeded {
324- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text)
324+ return ports.BrokerDaemonObservation{}, fmt.Errorf("daemon observation failed: %s", result.Text)
325325 }
326326 var catalog catalogue.RemoteCatalog
327327 if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil {
328- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err)
328+ return ports.BrokerDaemonObservation{}, fmt.Errorf("decode daemon observation: %w", err)
329329 }
330330 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
331331 return ports.BrokerDaemonObservation{}, err
M internal/usecase/client/attachment_input.go +3 -3
@@ -355,16 +355,16 @@ func (in *attachmentInput) apply(ctx context.Context, actions []paletteGeneratio
355355 switch action.kind {
356356 case paletteActionPublishCleared:
357357 if err := in.send(ctx, action.theme); err != nil {
358- return fmt.Errorf("vev: publishing theme: %w", err)
358+ return fmt.Errorf("publishing theme: %w", err)
359359 }
360360 case paletteActionPublishFinal:
361361 in.themes.update(func(current *protocol.Theme) { *current = action.theme })
362362 if err := in.send(ctx, action.theme); err != nil {
363- return fmt.Errorf("vev: publishing theme: %w", err)
363+ return fmt.Errorf("publishing theme: %w", err)
364364 }
365365 case paletteActionWriteDrain, paletteActionWriteBatch:
366366 if err := in.query.writeTerminalQuery([]byte(action.bytes)); err != nil {
367- return fmt.Errorf("vev: writing palette query: %w", err)
367+ return fmt.Errorf("writing palette query: %w", err)
368368 }
369369 case paletteActionArmDrainDeadline:
370370 in.drain.arm(in.clock, action.id, action.deadline)
M internal/usecase/client/attachment_session.go +5 -5
@@ -188,7 +188,7 @@ func (w *sessionAttachmentWorker) Run(ctx context.Context, fg AttachmentForegrou
188188 case protocol.ErrorMsg:
189189 return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: &ProtocolError{Code: message.Code, Text: message.Text}}
190190 default:
191- return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: unexpected %T before welcome", welcome)}
191+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("unexpected %T before welcome", welcome)}
192192 }
193193
194194 state, event := w.awaitInitialPublication(ctx, fg, stream, token, incoming)
@@ -228,7 +228,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
228228 // shape rule, a public generation of zero. Attached is published only after
229229 // that write, flush, and commit succeeded.
230230 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusConnecting), typed.Data); err != nil {
231- event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing initial output: %w", err)}
231+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing initial output: %w", err)}
232232 return state, &event
233233 }
234234 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
@@ -256,7 +256,7 @@ func (w *sessionAttachmentWorker) awaitInitialPublication(ctx context.Context, f
256256 // writes no bytes: the screen it commits is exactly the frame that just
257257 // drained.
258258 if err := fg.PublishAttached(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached)); err != nil {
259- event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing attached presentation: %w", err)}
259+ event := AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing attached presentation: %w", err)}
260260 return state, &event
261261 }
262262 return state, nil
@@ -419,7 +419,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
419419 }
420420 w.noteCommitted(fg, state.context)
421421 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), typed.Data); err != nil {
422- return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing output: %w", err)}
422+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing output: %w", err)}
423423 }
424424 if err := w.send(ctx, fg, stream, protocol.Ack{Epoch: state.epoch, State: state.state}); err != nil {
425425 return w.settle(ctx, fg, stream, token, err)
@@ -438,7 +438,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
438438 state = next
439439 w.noteCommitted(fg, state.context)
440440 if err := fg.Output(state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(fg, token)}, ports.UIStatusAttached), nil); err != nil {
441- return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("vev: publishing view update: %w", err)}
441+ return AttachmentEvent{Token: token, Kind: AttachmentEventFailed, Err: fmt.Errorf("publishing view update: %w", err)}
442442 }
443443 case protocol.UIReceipt:
444444 attachmentUIReceipt(fg, typed)
M internal/usecase/client/broker_operations.go +6 -6
@@ -86,10 +86,10 @@ type BrokerOperationRoute struct {
8686 // destination fence. It performs no I/O and consults no snapshot.
8787 func (r BrokerOperationRoute) Validate() error {
8888 if r.Epoch == 0 {
89- return errors.New("vev: broker operation route carries no epoch")
89+ return errors.New("broker operation route carries no epoch")
9090 }
9191 if err := r.Destination.Validate(); err != nil {
92- return fmt.Errorf("vev: broker operation route destination: %w", err)
92+ return fmt.Errorf("broker operation route destination: %w", err)
9393 }
9494 return nil
9595 }
@@ -108,10 +108,10 @@ type BrokerOperations struct {
108108 // missing dependency; the caller keeps ownership of the service lifetime.
109109 func NewBrokerOperations(service ports.BrokerNavigator, clock ports.Clock) (*BrokerOperations, error) {
110110 if supervisorNil(service) {
111- return nil, errors.New("vev: broker operations require a broker service")
111+ return nil, errors.New("broker operations require a broker service")
112112 }
113113 if supervisorNil(clock) {
114- return nil, errors.New("vev: broker operations require a clock")
114+ return nil, errors.New("broker operations require a clock")
115115 }
116116 return &BrokerOperations{service: service, clock: clock}, nil
117117 }
@@ -136,14 +136,14 @@ func (o *BrokerOperations) list(ctx context.Context, route BrokerOperationRoute,
136136 reply, err := o.exchange(ctx, route, mode, protocol.List{})
137137 if err != nil {
138138 if errors.Is(err, errBrokerOperationLost) {
139- return nil, fmt.Errorf("vev: reading session list: %w", err)
139+ return nil, fmt.Errorf("reading session list: %w", err)
140140 }
141141 return nil, err
142142 }
143143 sessions, ok := reply.(protocol.Sessions)
144144 if !ok {
145145 logBrokerOperationCause("list reply", fmt.Errorf("unexpected reply %T", reply))
146- return nil, fmt.Errorf("vev: unexpected reply %T to list", reply)
146+ return nil, fmt.Errorf("unexpected reply %T to list", reply)
147147 }
148148 return sessions.Sessions, nil
149149 }
M internal/usecase/client/initial_navigation.go +14 -14
@@ -99,54 +99,54 @@ func (n InitialNavigation) Validate() error {
9999 switch n.Kind {
100100 case InitialNavigationPicker:
101101 if n.Destination != (ports.BrokerEndpointFence{}) {
102- return errors.New("vev: picker navigation carries a destination")
102+ return errors.New("picker navigation carries a destination")
103103 }
104104 if n.Epoch != 0 {
105- return errors.New("vev: picker navigation carries an epoch")
105+ return errors.New("picker navigation carries an epoch")
106106 }
107107 if n.Name != "" {
108- return errors.New("vev: picker navigation carries a session name")
108+ return errors.New("picker navigation carries a session name")
109109 }
110110 if n.Target != (protocol.ExactSessionTarget{}) {
111- return errors.New("vev: picker navigation carries an exact target")
111+ return errors.New("picker navigation carries an exact target")
112112 }
113113 return nil
114114 case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed:
115115 default:
116- return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind))
116+ return fmt.Errorf("unknown initial navigation kind %d", uint8(n.Kind))
117117 }
118118 if err := n.Destination.Validate(); err != nil {
119- return fmt.Errorf("vev: initial navigation destination: %w", err)
119+ return fmt.Errorf("initial navigation destination: %w", err)
120120 }
121121 if !n.Destination.Local && n.Epoch == 0 {
122122 // A remote destination cannot be decided before the connection: it
123123 // requires the epoch of the snapshot that resolved its registration.
124- return errors.New("vev: remote initial navigation carries no epoch")
124+ return errors.New("remote initial navigation carries no epoch")
125125 }
126126 switch n.Kind {
127127 case InitialNavigationCreateEphemeral:
128128 if n.Name != "" {
129- return errors.New("vev: ephemeral creation navigation carries a session name")
129+ return errors.New("ephemeral creation navigation carries a session name")
130130 }
131131 if n.Target != (protocol.ExactSessionTarget{}) {
132- return errors.New("vev: ephemeral creation navigation carries an exact target")
132+ return errors.New("ephemeral creation navigation carries an exact target")
133133 }
134134 case InitialNavigationCreateNamed, InitialNavigationAttachNamed:
135135 if err := domain.ValidateSessionName(n.Name); err != nil {
136- return fmt.Errorf("vev: initial navigation session name: %w", err)
136+ return fmt.Errorf("initial navigation session name: %w", err)
137137 }
138138 if n.Target != (protocol.ExactSessionTarget{}) {
139- return errors.New("vev: named navigation carries an exact target")
139+ return errors.New("named navigation carries an exact target")
140140 }
141141 case InitialNavigationAttachExact:
142142 if n.Epoch == 0 {
143- return errors.New("vev: exact attach navigation carries no epoch")
143+ return errors.New("exact attach navigation carries no epoch")
144144 }
145145 if n.Name != "" {
146- return errors.New("vev: exact attach navigation carries a session name")
146+ return errors.New("exact attach navigation carries a session name")
147147 }
148148 if err := n.Target.Validate(); err != nil {
149- return fmt.Errorf("vev: initial navigation target: %w", err)
149+ return fmt.Errorf("initial navigation target: %w", err)
150150 }
151151 }
152152 return nil
M internal/usecase/client/move_picker_overlay.go +1 -1
@@ -291,7 +291,7 @@ func (p *attachmentMovePicker) paint(state outputApplyState) error {
291291 }
292292 uiContext := state.uiContext(ports.UIContext{Generation: attachmentActionableGeneration(p.fg, p.fg.Token())}, ports.UIStatusAttached)
293293 if err := p.overlay.overlayOutput(uiContext, frame); err != nil {
294- return fmt.Errorf("vev: publishing move picker: %w", err)
294+ return fmt.Errorf("publishing move picker: %w", err)
295295 }
296296 return nil
297297 }
M internal/usecase/client/picker_catalogue.go +2 -2
@@ -120,9 +120,9 @@ func (e pickerCatalogueError) noticeText() string {
120120
121121 func (e pickerCatalogueError) Error() string {
122122 if e.Text == "" {
123- return "vev: picker selection " + e.Code.String()
123+ return "picker selection " + e.Code.String()
124124 }
125- return "vev: picker selection " + e.Code.String() + ": " + e.Text
125+ return "picker selection " + e.Code.String() + ": " + e.Text
126126 }
127127
128128 // pickerSelectionKind names one closed admission variant a resolved selection
M internal/usecase/client/runtime_primitives.go +2 -2
@@ -16,9 +16,9 @@ type ProtocolError struct {
1616
1717 func (e *ProtocolError) Error() string {
1818 if e.Text == "" {
19- return fmt.Sprintf("vev: daemon rejected attach (code %d)", e.Code)
19+ return fmt.Sprintf("daemon rejected attach (code %d)", e.Code)
2020 }
21- return fmt.Sprintf("vev: %s", e.Text)
21+ return e.Text
2222 }
2323
2424 func newClientID() [16]byte {
M internal/usecase/client/supervisor.go +7 -7
@@ -478,13 +478,13 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
478478 }
479479 cfg.SessionEnvironment = cfg.SessionEnvironment.Clone()
480480 if supervisorNil(cfg.Connector) {
481- return nil, errors.New("vev: supervisor requires a broker connector")
481+ return nil, errors.New("supervisor requires a broker connector")
482482 }
483483 if supervisorNil(cfg.Terminal) {
484- return nil, errors.New("vev: supervisor requires a terminal")
484+ return nil, errors.New("supervisor requires a terminal")
485485 }
486486 if supervisorNil(cfg.Clock) {
487- return nil, errors.New("vev: supervisor requires a clock")
487+ return nil, errors.New("supervisor requires a clock")
488488 }
489489 if cfg.Render == nil {
490490 cfg.Render = func(State) {}
@@ -499,10 +499,10 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
499499 }
500500 if cfg.ResolveInitialNavigation != nil {
501501 if cfg.InitialNavigation != (InitialNavigation{}) {
502- return nil, errors.New("vev: supervisor accepts either an initial navigation or a resolver, not both")
502+ return nil, errors.New("supervisor accepts either an initial navigation or a resolver, not both")
503503 }
504504 } else if err := cfg.InitialNavigation.Validate(); err != nil {
505- return nil, fmt.Errorf("vev: supervisor initial navigation: %w", err)
505+ return nil, fmt.Errorf("supervisor initial navigation: %w", err)
506506 }
507507 logger := cfg.Logger
508508 if logger == nil {
@@ -558,7 +558,7 @@ func (s *Supervisor) State() State {
558558 func (s *Supervisor) Run(ctx context.Context) (retErr error) {
559559 restore, err := s.cfg.Terminal.EnterRaw()
560560 if err != nil {
561- return fmt.Errorf("vev: entering raw mode: %w", err)
561+ return fmt.Errorf("entering raw mode: %w", err)
562562 }
563563 if restore == nil {
564564 restore = func() error { return nil }
@@ -566,7 +566,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
566566 restoreTerminal := sync.OnceValue(restore)
567567 defer func() {
568568 if rerr := restoreTerminal(); rerr != nil && retErr == nil {
569- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
569+ retErr = fmt.Errorf("restoring terminal: %w", rerr)
570570 }
571571 }()
572572
M internal/usecase/client/supervisor_attach.go +3 -3
@@ -337,7 +337,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
337337 var err error
338338 geometry, err = s.cfg.Terminal.Geometry()
339339 if err != nil {
340- return nil, fmt.Errorf("vev: reading terminal geometry: %w", err)
340+ return nil, fmt.Errorf("reading terminal geometry: %w", err)
341341 }
342342 }
343343 environment := s.cfg.AttachmentEnvironment
@@ -380,7 +380,7 @@ func (s *Supervisor) takeInitialNavigation(snapshot ports.BrokerSnapshot) (Initi
380380 // publication consumes the intent and reports a bounded refusal instead of
381381 // handing an unvalidated authority to the resolver or the request.
382382 if err := snapshot.Validate(); err != nil {
383- return InitialNavigation{}, true, fmt.Errorf("vev: initial navigation snapshot: %w", err)
383+ return InitialNavigation{}, true, fmt.Errorf("initial navigation snapshot: %w", err)
384384 }
385385 s.mu.Lock()
386386 if s.navigationConsumed {
@@ -421,7 +421,7 @@ func (s *Supervisor) takeInitialNavigation(snapshot ports.BrokerSnapshot) (Initi
421421 // not carry yet (for example attach-or-create before the host's first
422422 // observation). The supervisor keeps the intent armed and resolves it again on
423423 // later publications, bounded by initialNavigationObservationBudget.
424-var ErrInitialNavigationNotObserved = errors.New("vev: initial navigation target is not observed yet")
424+var ErrInitialNavigationNotObserved = errors.New("initial navigation target is not observed yet")
425425
426426 // InitialNavigationNotObserved is the resolver's ErrInitialNavigationNotObserved
427427 // naming the configured endpoint whose fresh observation it needs; the
M internal/usecase/daemon/daemon.go +112 -103
@@ -167,6 +167,118 @@ type Daemon struct {
167167
168168 paletteRecentMu sync.Mutex
169169 paletteRecent []string
170+ // daemonTestHooks holds deterministic test seams; production leaves every
171+ // hook nil.
172+ daemonTestHooks
173+ ptys ports.PTYFactory
174+ clock ports.Clock
175+ log *slog.Logger
176+ runtimeObserver ports.RuntimeObserver
177+ baseEnv []string
178+ shell string
179+ shellArgs []string
180+ shellOverride bool
181+ persistEnabled bool
182+ catalogue ports.Catalogue
183+ catalogueRecords []domain.CatalogueRecord
184+ catalogueRecordsProvided bool
185+ // snapshotRepository is the sole checkpoint storage contract.
186+ snapshotRepository ports.SnapshotRepository
187+ recovery *recoveryusecase.Coordinator
188+ snapshotGarbageCollection bool
189+ maintenanceWorkerCancel context.CancelFunc
190+ maintenanceWorkerDone chan struct{}
191+ // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
192+ // restoration reconciles durable checkpoints, so it is a durable writer and
193+ // is guarded by snapshotWorkerMu with the other two.
194+ restoreWorkerDone chan struct{}
195+ snapsEnabled bool
196+ noticeStore ports.NoticeStore
197+ snapshotJobs chan *snapshotCapture
198+ // snapshotAdmitted contains every capture accepted by either worker queue,
199+ // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
200+ snapshotAdmitted map[*snapshotCapture]struct{}
201+ // snapshotWake wakes the repository scheduler when a session becomes dirty
202+ // or an attempt completes. It is never closed and producers only send
203+ // non-blockingly.
204+ snapshotWake chan struct{}
205+ snapshotWorkerMu sync.Mutex
206+ snapshotWorkerID uint64
207+ snapshotWorkerCtx context.Context
208+ snapshotWorkerCancel context.CancelFunc
209+ snapshotWorkerDone chan struct{}
210+ snapshotWorkerFlush chan struct{}
211+ snapshotWorkerFinalWake chan struct{}
212+ // snapshotFinalJobs coalesces terminal captures by session when the bounded
213+ // regular queue is full. It retains at most snapshotFinalQueueCapacity named
214+ // sessions, each with only its newest terminal state while the worker blocks.
215+ snapshotFinalJobs map[*session]*snapshotCapture
216+ snapshotFinalOrder []*session
217+ snapshotWorkerClosing bool
218+ snapshotWorkerInFlight *snapshotCapture
219+ // snapshotNoticeMu guards the active global persistence failure signature.
220+ // It is separate from snapshotWorkerMu so notice routing cannot block a
221+ // producer or a repository worker.
222+ snapshotNoticeMu sync.Mutex
223+ snapshotActiveFailureSignature string
224+ shutdownNoticeMu sync.Mutex
225+ shutdownNoticedSessions map[string]struct{}
226+ restoreDone chan struct{}
227+ restoreOnce sync.Once
228+ procCwd func(int) (string, error)
229+ procComm func(int) (string, error)
230+ procArgv func(int) ([]string, error)
231+ procGroupArgv func(int, int) ([]string, error)
232+ dirOrHome func(string) string
233+ bindings atomic.Pointer[keys.Bindings]
234+ codeOverrides atomic.Pointer[map[string]string]
235+ restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
236+ floatingConfig atomic.Pointer[domain.FloatingConfig]
237+ copyConfig atomic.Pointer[domain.CopyConfig]
238+ paletteConfig atomic.Pointer[domain.PaletteConfig]
239+ navConfig atomic.Pointer[domain.NavConfig]
240+ tabsConfig atomic.Pointer[domain.TabsConfig]
241+ ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
242+ scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
243+ themeConfig atomic.Pointer[themeConfigSnapshot]
244+ barScripts *barScriptState
245+ notices *noticeCenter
246+ resumeParkGrace time.Duration
247+ suspendedSafetyExpiry time.Duration
248+ // tempDir overrides os.TempDir() for clipboard-image-transfer writes
249+ // (see clipboard.go); empty means use os.TempDir().
250+ tempDir string
251+
252+ serveCtx context.Context
253+ serveCancel context.CancelFunc
254+
255+ // hardCtx force-closes connection transports on shutdown, but only after
256+ // shutdownAll has delivered graceful Detached notices — keeping it separate
257+ // from serveCtx so a parent-context cancel never races the notice.
258+ hardCtx context.Context
259+ hardCancel context.CancelFunc
260+
261+ // done is closed exactly once by an explicit daemon stop (shutdownAll) to
262+ // break Serve's accept loop. It is deliberately not closed by final session
263+ // removal or by a KillAll purge: an empty registry and a purged registry
264+ // both keep the daemon serving.
265+ done chan struct{}
266+ doneOnce sync.Once
267+
268+ // sessWg owns attention animation, bar-script polling, CWD sampling,
269+ // snapshot save/restore, and floating-session launch goroutines.
270+ sessWg sync.WaitGroup
271+ connWg sync.WaitGroup // per-connection handler goroutines
272+ // attachmentCleanupWg owns replacement transport closes and retired render
273+ // worker joins. Only connection handlers add work; Serve waits for those
274+ // handlers before joining this group, so no Add races its terminal Wait.
275+ attachmentCleanupWg sync.WaitGroup
276+}
277+
278+// daemonTestHooks are deterministic seams that let tests pause or observe the
279+// daemon inside narrow concurrency windows. Production never sets them, and no
280+// hook may perform external work while locks are held.
281+type daemonTestHooks struct {
170282 // beforeRecentSessionHandoff is a deterministic test seam for the narrow
171283 // interval between JRS validation and its committed hand-off.
172284 beforeRecentSessionHandoff func()
@@ -284,109 +396,6 @@ type Daemon struct {
284396 // and before it freezes the attachment effect gate, so tests can win
285397 // activation, disconnect, or replacement first. It is a test-only seam.
286398 beforeSuspendedExpiryFreeze func(*attachedClient)
287- ptys ports.PTYFactory
288- clock ports.Clock
289- log *slog.Logger
290- runtimeObserver ports.RuntimeObserver
291- baseEnv []string
292- shell string
293- shellArgs []string
294- shellOverride bool
295- persistEnabled bool
296- catalogue ports.Catalogue
297- catalogueRecords []domain.CatalogueRecord
298- catalogueRecordsProvided bool
299- // snapshotRepository is the sole checkpoint storage contract.
300- snapshotRepository ports.SnapshotRepository
301- recovery *recoveryusecase.Coordinator
302- snapshotGarbageCollection bool
303- maintenanceWorkerCancel context.CancelFunc
304- maintenanceWorkerDone chan struct{}
305- // restoreWorkerDone is the restoration goroutine's ownership signal. Startup
306- // restoration reconciles durable checkpoints, so it is a durable writer and
307- // is guarded by snapshotWorkerMu with the other two.
308- restoreWorkerDone chan struct{}
309- snapsEnabled bool
310- noticeStore ports.NoticeStore
311- snapshotJobs chan *snapshotCapture
312- // snapshotAdmitted contains every capture accepted by either worker queue,
313- // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
314- snapshotAdmitted map[*snapshotCapture]struct{}
315- // snapshotWake wakes the repository scheduler when a session becomes dirty
316- // or an attempt completes. It is never closed and producers only send
317- // non-blockingly.
318- snapshotWake chan struct{}
319- snapshotWorkerMu sync.Mutex
320- snapshotWorkerID uint64
321- snapshotWorkerCtx context.Context
322- snapshotWorkerCancel context.CancelFunc
323- snapshotWorkerDone chan struct{}
324- snapshotWorkerFlush chan struct{}
325- snapshotWorkerFinalWake chan struct{}
326- // snapshotFinalJobs coalesces terminal captures by session when the bounded
327- // regular queue is full. It retains at most snapshotFinalQueueCapacity named
328- // sessions, each with only its newest terminal state while the worker blocks.
329- snapshotFinalJobs map[*session]*snapshotCapture
330- snapshotFinalOrder []*session
331- snapshotWorkerClosing bool
332- snapshotWorkerInFlight *snapshotCapture
333- // snapshotNoticeMu guards the active global persistence failure signature.
334- // It is separate from snapshotWorkerMu so notice routing cannot block a
335- // producer or a repository worker.
336- snapshotNoticeMu sync.Mutex
337- snapshotActiveFailureSignature string
338- shutdownNoticeMu sync.Mutex
339- shutdownNoticedSessions map[string]struct{}
340- restoreDone chan struct{}
341- restoreOnce sync.Once
342- procCwd func(int) (string, error)
343- procComm func(int) (string, error)
344- procArgv func(int) ([]string, error)
345- procGroupArgv func(int, int) ([]string, error)
346- dirOrHome func(string) string
347- bindings atomic.Pointer[keys.Bindings]
348- codeOverrides atomic.Pointer[map[string]string]
349- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
350- floatingConfig atomic.Pointer[domain.FloatingConfig]
351- copyConfig atomic.Pointer[domain.CopyConfig]
352- paletteConfig atomic.Pointer[domain.PaletteConfig]
353- navConfig atomic.Pointer[domain.NavConfig]
354- tabsConfig atomic.Pointer[domain.TabsConfig]
355- ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
356- scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
357- themeConfig atomic.Pointer[themeConfigSnapshot]
358- barScripts *barScriptState
359- notices *noticeCenter
360- resumeParkGrace time.Duration
361- suspendedSafetyExpiry time.Duration
362- // tempDir overrides os.TempDir() for clipboard-image-transfer writes
363- // (see clipboard.go); empty means use os.TempDir().
364- tempDir string
365-
366- serveCtx context.Context
367- serveCancel context.CancelFunc
368-
369- // hardCtx force-closes connection transports on shutdown, but only after
370- // shutdownAll has delivered graceful Detached notices — keeping it separate
371- // from serveCtx so a parent-context cancel never races the notice.
372- hardCtx context.Context
373- hardCancel context.CancelFunc
374-
375- // done is closed exactly once by an explicit daemon stop (shutdownAll) to
376- // break Serve's accept loop. It is deliberately not closed by final session
377- // removal or by a KillAll purge: an empty registry and a purged registry
378- // both keep the daemon serving.
379- done chan struct{}
380- doneOnce sync.Once
381-
382- // sessWg owns attention animation, bar-script polling, CWD sampling,
383- // snapshot save/restore, and floating-session launch goroutines.
384- sessWg sync.WaitGroup
385- connWg sync.WaitGroup // per-connection handler goroutines
386- // attachmentCleanupWg owns replacement transport closes and retired render
387- // worker joins. Only connection handlers add work; Serve waits for those
388- // handlers before joining this group, so no Add races its terminal Wait.
389- attachmentCleanupWg sync.WaitGroup
390399 }
391400
392401 type parkedAttachment struct {
M internal/usecase/daemon/notify_test.go +1 -1
@@ -721,7 +721,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
721721 }
722722
723723 // TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
724-// failure contract (Task 13): app/run.go queues this exact code and message
724+// failure contract: the app daemon composition queues this exact code and message
725725 // via NotifyGlobal before any client has attached, and the first attach must
726726 // see it via firstPaint's pending-queue drain.
727727 func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
M main.go +3 -9
@@ -3,7 +3,6 @@ package main
33 import (
44 "fmt"
55 "os"
6- "strings"
76
87 "github.com/bnema/vev/internal/app"
98 )
@@ -15,13 +14,8 @@ func main() {
1514 }
1615 }
1716
18-// cliError adds the sole user-facing CLI prefix. Errors may already include
19-// it when they originate from a runtime component, so remove any such prefix
20-// before rendering.
17+// cliError adds the sole user-facing CLI prefix; internal errors never carry
18+// it.
2119 func cliError(err error) string {
22- message := err.Error()
23- for strings.HasPrefix(message, "vev:") {
24- message = strings.TrimSpace(strings.TrimPrefix(message, "vev:"))
25- }
26- return "vev: " + message
20+ return "vev: " + err.Error()
2721 }
M main_test.go +3 -17
@@ -2,6 +2,7 @@ package main
22
33 import (
44 "errors"
5+ "fmt"
56 "testing"
67
78 "github.com/bnema/vev/internal/app"
@@ -24,25 +25,10 @@ func TestCLIError(t *testing.T) {
2425 want: "vev: invalid session name: must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}",
2526 },
2627 {
27- name: "daemon error",
28- err: errors.New("vev: daemon listen: address already in use"),
28+ name: "wrapped runtime error",
29+ err: fmt.Errorf("daemon listen: %w", errors.New("address already in use")),
2930 want: "vev: daemon listen: address already in use",
3031 },
31- {
32- name: "session error",
33- err: errors.New("vev: no such session: work"),
34- want: "vev: no such session: work",
35- },
36- {
37- name: "repeated prefix",
38- err: errors.New("vev: vev: message"),
39- want: "vev: message",
40- },
41- {
42- name: "prefix whitespace",
43- err: errors.New("vev:\t message"),
44- want: "vev: message",
45- },
4632 }
4733
4834 for _, tt := range tests {

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