detached b6528f0

feat(client): project the picker from broker snapshots

Add the client-owned picker: rows come from broker snapshots instead of a
serving daemon's catalogue, and a selection resolves to an exact broker stream
request. Offline and unactivated; no production composition uses it yet.

- picker_catalogue.go projects ports.BrokerSnapshot into stable host/session
  rows, merging observations monotonically within an epoch and resyncing
  wholesale on a new epoch, with keys derived only from daemon and session
  identity so a rename keeps its row and a replacement changes it.
- Classification follows availability before compatibility: an unobserved or
  unreachable daemon carries no protocol version, so it reports as
  unavailable/refreshing and refuses as unavailable rather than as a version
  mismatch. Auth-failed and malformed hosts keep their own reasons.
- Selection revalidates the exact identity against the latest snapshot and
  refuses with a typed bounded code when it is gone, replaced, stale,
  incompatible, or unavailable; a snapshot failing its own contract is
  rejected and the previous projection stays current.
- picker_controller.go renders the latest snapshot immediately, folds into the
  supervisor's single terminal input lifetime (no second reader, no path to a
  PTY or session), surfaces bounded notices, and resolves the row the user
  committed rather than whichever row is displayed when the request is built.
commit
b6528f07704a098d00546f1e130c2e4e7b030ebd
author
brice · · 2026-09-19 09:52 UTC
parent
a734bf5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +3069 -29
A internal/usecase/client/picker_catalogue.go +936 -0
@@ -0,0 +1,936 @@
1+package client
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+)
17+
18+// Client-owned picker catalogue (Plan 001 P5.2b, offline and unactivated).
19+//
20+// The autonomous supervisor owns the broker connection and the single terminal
21+// input lifetime; this file owns the catalogue the picker presents, projected
22+// from the broker's own observations rather than from a serving-daemon
23+// catalogue. It is the P5.2b replacement source for the daemon-served
24+// protocol.PickerSnapshot: rows are derived from ports.BrokerSnapshot, the
25+// opaque keys stay client-local, and a selection resolves to an exact
26+// ports.BrokerOpenStreamRequest instead of a daemon-side key handoff.
27+//
28+// The projection preserves configured authority and observed state separately,
29+// exactly as ports.BrokerDaemonObservation does:
30+//
31+// - daemon identity is the local flag or the remote endpoint plus the
32+// registration incarnation; a re-registered endpoint is an identity
33+// replacement and its rows never survive.
34+// - session identity is the catalogue lifecycle ID; a same-name replacement
35+// resolves as "replaced", never by name.
36+// - freshness is the LastSuccess window against the injected clock.
37+// - compatibility is the observed protocol version against the policy's
38+// required version.
39+// - availability is the observed RemoteAvailability.
40+//
41+// Updates are monotonic: a publication that does not supersede the applied
42+// epoch/revision is ignored, and a per-daemon observation that regresses
43+// (older LastSuccess and no newer failure episode, same incarnation) keeps the
44+// newer observed state while still refreshing configured authority. Row order
45+// is stable: hosts keep their first-seen position and unrelated updates never
46+// reshuffle them. Nothing here attaches to a daemon, starts one, or writes a
47+// session.
48+
49+const (
50+ // pickerCatalogueSourceID names the single client-owned picker source.
51+ pickerCatalogueSourceID = "broker"
52+ // pickerCatalogueDefaultFreshness bounds how old a LastSuccess may be
53+ // before a row is displayed as stale and refused at resolution.
54+ pickerCatalogueDefaultFreshness = 30 * time.Second
55+ // pickerCatalogueMaxRetiredRefs bounds the retired-key fence used to
56+ // distinguish "gone" from "replaced" after a row leaves the projection.
57+ pickerCatalogueMaxRetiredRefs = 128
58+)
59+
60+// pickerCatalogueErrorCode is the closed refusal taxonomy for a client-owned
61+// picker selection. The zero value is never produced: a refusal always names
62+// why the exact selection cannot be resolved.
63+type pickerCatalogueErrorCode uint8
64+
65+const (
66+ pickerCatalogueUnknown pickerCatalogueErrorCode = iota + 1
67+ pickerCatalogueGone
68+ pickerCatalogueReplaced
69+ pickerCatalogueStale
70+ pickerCatalogueIncompatible
71+ pickerCatalogueUnavailable
72+ pickerCatalogueEpochStale
73+ pickerCatalogueInvalidName
74+ pickerCatalogueNoSelection
75+)
76+
77+func (c pickerCatalogueErrorCode) String() string {
78+ switch c {
79+ case pickerCatalogueUnknown:
80+ return "unknown"
81+ case pickerCatalogueGone:
82+ return "gone"
83+ case pickerCatalogueReplaced:
84+ return "replaced"
85+ case pickerCatalogueStale:
86+ return "stale"
87+ case pickerCatalogueIncompatible:
88+ return "incompatible"
89+ case pickerCatalogueUnavailable:
90+ return "unavailable"
91+ case pickerCatalogueEpochStale:
92+ return "stale_epoch"
93+ case pickerCatalogueInvalidName:
94+ return "invalid_name"
95+ case pickerCatalogueNoSelection:
96+ return "no_selection"
97+ default:
98+ return "unknown"
99+ }
100+}
101+
102+// pickerCatalogueError is a typed, bounded refusal. It never carries a raw
103+// endpoint, credential, or adapter diagnostic as display text.
104+type pickerCatalogueError struct {
105+ Code pickerCatalogueErrorCode
106+ Text string
107+}
108+
109+func (e pickerCatalogueError) Error() string {
110+ if e.Text == "" {
111+ return "vev: picker selection " + e.Code.String()
112+ }
113+ return "vev: picker selection " + e.Code.String() + ": " + e.Text
114+}
115+
116+// pickerCatalogueErrorIs reports whether err is a typed catalogue refusal with
117+// the supplied code, so callers classify without matching message text.
118+func pickerCatalogueErrorIs(err error, code pickerCatalogueErrorCode) bool {
119+ var typed pickerCatalogueError
120+ return errors.As(err, &typed) && typed.Code == code
121+}
122+
123+// pickerSelectionKind names one closed admission variant a resolved selection
124+// can carry.
125+type pickerSelectionKind uint8
126+
127+const (
128+ // pickerSelectionExact attaches to one exact session lifecycle.
129+ pickerSelectionExact pickerSelectionKind = iota + 1
130+ // pickerSelectionCreateNamed creates one named session.
131+ pickerSelectionCreateNamed
132+ // pickerSelectionCreateEphemeral creates one ephemeral session.
133+ pickerSelectionCreateEphemeral
134+)
135+
136+// pickerSelectionRef is the client's exact selection identity, captured when a
137+// row was projected. Resolution revalidates it against the latest snapshot; it
138+// is never reconstructed from a display label.
139+type pickerSelectionRef struct {
140+ kind pickerSelectionKind
141+ epoch ports.BrokerEpoch
142+ local bool
143+ endpoint string
144+ registration domain.RemoteRegistration
145+ lifecycle domain.SessionLifecycleID
146+ name string
147+ createName string
148+}
149+
150+// pickerResolveBase carries the live broker connection identity a resolved
151+// request must be fenced to. The catalogue supplies everything else.
152+type pickerResolveBase struct {
153+ Connection ports.BrokerConnectionID
154+ Stream ports.BrokerStreamID
155+ Env []string
156+}
157+
158+// pickerHostState is one projected daemon and its newest accepted
159+// observation.
160+type pickerHostState struct {
161+ observation ports.BrokerDaemonObservation
162+}
163+
164+// pickerCatalogueConfig supplies the fresh-window clock. Clock is optional and
165+// falls back to the system clock; Freshness defaults to
166+// pickerCatalogueDefaultFreshness.
167+type pickerCatalogueConfig struct {
168+ Clock ports.Clock
169+ Freshness time.Duration
170+}
171+
172+// pickerCatalogue is the client-owned, monotonic projection of broker daemon
173+// observations into ordered picker rows. It is safe for concurrent use: the
174+// supervisor applies publications from its run path while the picker loop
175+// resolves selections.
176+type pickerCatalogue struct {
177+ clock ports.Clock
178+ freshness time.Duration
179+
180+ mu sync.Mutex
181+ snapshot ports.BrokerSnapshot
182+ hosts map[string]*pickerHostState
183+ order []string
184+ refs map[string]pickerSelectionRef
185+ retired map[string]pickerSelectionRef
186+ retiredOrder []string
187+ lines []protocol.PickerLine
188+ cursor protocol.PickerCursor
189+}
190+
191+// newPickerCatalogue builds an empty projection.
192+func newPickerCatalogue(cfg pickerCatalogueConfig) *pickerCatalogue {
193+ clock := cfg.Clock
194+ if supervisorNil(clock) {
195+ clock = systemClock{}
196+ }
197+ freshness := cfg.Freshness
198+ if freshness <= 0 {
199+ freshness = pickerCatalogueDefaultFreshness
200+ }
201+ return &pickerCatalogue{
202+ clock: clock,
203+ freshness: freshness,
204+ hosts: make(map[string]*pickerHostState),
205+ refs: make(map[string]pickerSelectionRef),
206+ retired: make(map[string]pickerSelectionRef),
207+ }
208+}
209+
210+// Apply folds one broker publication into the projection. It reports whether
211+// the projection changed. The publication is the projection's trust boundary:
212+// a snapshot that fails its own contract is rejected outright and the previous
213+// projection is kept. A valid publication that does not supersede the applied
214+// epoch/revision is also ignored, so a slow or out-of-order snapshot can never
215+// regress a row.
216+func (c *pickerCatalogue) Apply(snapshot ports.BrokerSnapshot) bool {
217+ if c == nil {
218+ return false
219+ }
220+ c.mu.Lock()
221+ defer c.mu.Unlock()
222+ if snapshot.Epoch == 0 || snapshot.Revision == 0 {
223+ return false
224+ }
225+ if err := snapshot.Validate(); err != nil {
226+ return false
227+ }
228+ if !snapshot.Supersedes(c.snapshot) {
229+ return false
230+ }
231+ if c.snapshot.Epoch != 0 && snapshot.Epoch != c.snapshot.Epoch {
232+ // A new broker process is a full resynchronization: every key, row, and
233+ // retired fence from the previous epoch is invalid.
234+ c.resetLocked()
235+ }
236+ c.snapshot = snapshot.Clone()
237+ c.mergeHostsLocked()
238+ c.projectLocked()
239+ return true
240+}
241+
242+// Epoch reports the applied broker epoch, or zero before the first publication.
243+func (c *pickerCatalogue) Epoch() ports.BrokerEpoch {
244+ if c == nil {
245+ return 0
246+ }
247+ c.mu.Lock()
248+ defer c.mu.Unlock()
249+ return c.snapshot.Epoch
250+}
251+
252+// Revision reports the applied broker revision, or zero before the first
253+// publication.
254+func (c *pickerCatalogue) Revision() ports.BrokerRevision {
255+ if c == nil {
256+ return 0
257+ }
258+ c.mu.Lock()
259+ defer c.mu.Unlock()
260+ return c.snapshot.Revision
261+}
262+
263+// Snapshot returns a defensive copy of the applied broker snapshot.
264+func (c *pickerCatalogue) Snapshot() ports.BrokerSnapshot {
265+ if c == nil {
266+ return ports.BrokerSnapshot{}
267+ }
268+ c.mu.Lock()
269+ defer c.mu.Unlock()
270+ return c.snapshot.Clone()
271+}
272+
273+// Lines returns a copy of the current ordered rows.
274+func (c *pickerCatalogue) Lines() []protocol.PickerLine {
275+ if c == nil {
276+ return nil
277+ }
278+ c.mu.Lock()
279+ defer c.mu.Unlock()
280+ return append([]protocol.PickerLine(nil), c.lines...)
281+}
282+
283+// Cursor returns the projection's cursor hint.
284+func (c *pickerCatalogue) Cursor() protocol.PickerCursor {
285+ if c == nil {
286+ return protocol.PickerCursor{}
287+ }
288+ c.mu.Lock()
289+ defer c.mu.Unlock()
290+ return c.cursor
291+}
292+
293+// projection returns the current rows and cursor hint together.
294+func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) {
295+ if c == nil {
296+ return nil, protocol.PickerCursor{}
297+ }
298+ c.mu.Lock()
299+ defer c.mu.Unlock()
300+ return append([]protocol.PickerLine(nil), c.lines...), c.cursor
301+}
302+
303+// Ref returns the exact selection identity recorded for one row key, including
304+// a retired key that has left the current projection.
305+func (c *pickerCatalogue) Ref(key string) (pickerSelectionRef, bool) {
306+ if c == nil {
307+ return pickerSelectionRef{}, false
308+ }
309+ c.mu.Lock()
310+ defer c.mu.Unlock()
311+ if ref, ok := c.refs[key]; ok {
312+ return ref, true
313+ }
314+ ref, ok := c.retired[key]
315+ return ref, ok
316+}
317+
318+// Resolve revalidates one row key against the LATEST applied snapshot and
319+// returns the exact broker stream request it names. It refuses with a typed
320+// refusal when the identity is gone, replaced, stale, incompatible, or
321+// unavailable, and it never invents identity.
322+func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
323+ if c == nil {
324+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
325+ }
326+ c.mu.Lock()
327+ defer c.mu.Unlock()
328+ ref, ok := c.refs[key]
329+ if !ok {
330+ ref, ok = c.retired[key]
331+ }
332+ if !ok {
333+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
334+ }
335+ return c.resolveRefLocked(ref, base)
336+}
337+
338+// ResolveRef revalidates an exact selection identity a driver captured when it
339+// displayed the row. It is the entry point for a selection held across a
340+// broker epoch or an equivalent output boundary, where the key alone would be
341+// re-bound to a newer identity.
342+func (c *pickerCatalogue) ResolveRef(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
343+ if c == nil {
344+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
345+ }
346+ c.mu.Lock()
347+ defer c.mu.Unlock()
348+ return c.resolveRefLocked(ref, base)
349+}
350+
351+// ResolveCreation revalidates a named or ephemeral creation against the latest
352+// snapshot. The created identity is not invented: the daemon must be present,
353+// compatible, available, and fresh, and a named creation needs a valid session
354+// name.
355+func (c *pickerCatalogue) ResolveCreation(local bool, endpoint string, kind pickerSelectionKind, name string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
356+ if c == nil {
357+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
358+ }
359+ if kind != pickerSelectionCreateNamed && kind != pickerSelectionCreateEphemeral {
360+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "not a creation selection"}
361+ }
362+ c.mu.Lock()
363+ defer c.mu.Unlock()
364+ ref := pickerSelectionRef{kind: kind, epoch: c.snapshot.Epoch, local: local, endpoint: endpoint, createName: name}
365+ if !local {
366+ host := c.hostForEndpointLocked(endpoint)
367+ if host == nil {
368+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
369+ }
370+ ref.registration = host.observation.Registration
371+ }
372+ return c.resolveRefLocked(ref, base)
373+}
374+
375+func (c *pickerCatalogue) resetLocked() {
376+ c.hosts = make(map[string]*pickerHostState)
377+ c.order = nil
378+ c.refs = make(map[string]pickerSelectionRef)
379+ c.retired = make(map[string]pickerSelectionRef)
380+ c.retiredOrder = nil
381+ c.lines = nil
382+ c.cursor = protocol.PickerCursor{}
383+}
384+
385+// mergeHostsLocked folds the newest publication into the per-daemon state. A
386+// daemon keeps its observed state when the incoming observation regresses, and
387+// an incarnation change replaces the daemon's identity wholesale.
388+func (c *pickerCatalogue) mergeHostsLocked() {
389+ next := make(map[string]*pickerHostState, len(c.snapshot.Daemons))
390+ for i := range c.snapshot.Daemons {
391+ observation := c.snapshot.Daemons[i]
392+ key := pickerDaemonKey(observation.Local, observation.Endpoint)
393+ current, ok := c.hosts[key]
394+ if ok && pickerSameIncarnation(current.observation, observation) && !pickerObservationNewer(observation, current.observation) {
395+ // The publication is newer but this daemon's own observation is not:
396+ // keep the newer observed state and still apply configured authority.
397+ next[key] = &pickerHostState{
398+ observation: pickerMergeAuthority(current.observation, observation),
399+ }
400+ continue
401+ }
402+ next[key] = &pickerHostState{observation: observation}
403+ }
404+ c.hosts = next
405+ c.order = pickerStableHostOrder(c.order, c.snapshot.Daemons)
406+ // Tombstones fence a retired registration even if a publication still
407+ // carries it; a re-added endpoint with a fresh incarnation is never fenced.
408+ for _, tombstone := range c.snapshot.Removed {
409+ for key, host := range c.hosts {
410+ if !host.observation.Local && host.observation.Endpoint == tombstone.Endpoint && tombstone.Fences(host.observation.Registration) {
411+ delete(c.hosts, key)
412+ }
413+ }
414+ }
415+}
416+
417+// projectLocked rebuilds the ordered rows and their opaque keys from the
418+// current host state. It is deterministic: the same inputs always produce the
419+// same keys, order, and actions.
420+func (c *pickerCatalogue) projectLocked() {
421+ now := c.clock.Now()
422+ refs := make(map[string]pickerSelectionRef)
423+ lines := make([]protocol.PickerLine, 0, len(c.hosts)*2)
424+ for _, key := range c.order {
425+ host, ok := c.hosts[key]
426+ if !ok {
427+ continue
428+ }
429+ observation := host.observation
430+ fresh := c.freshLocked(observation, now)
431+ compatible := pickerObservationCompatible(observation)
432+ available := observation.Availability == domain.RemoteAvailabilityReachable
433+ eligible := compatible && available && fresh
434+ origin := pickerOriginLabel(observation)
435+
436+ lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true})
437+ lines = append(lines, protocol.PickerLine{
438+ Key: pickerHostRowKey(observation),
439+ Kind: protocol.PickerLineHost,
440+ Label: origin,
441+ Detail: pickerHostDetail(observation),
442+ Status: pickerObservationStatus(observation, fresh),
443+ StatusDetail: pickerObservationReason(observation, fresh),
444+ Focusable: true,
445+ })
446+
447+ for i := range observation.Sessions {
448+ session := observation.Sessions[i]
449+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
450+ // A session without a lifecycle cannot be addressed exactly and
451+ // is never invented into one.
452+ continue
453+ }
454+ ref := pickerSelectionRef{
455+ kind: pickerSelectionExact,
456+ epoch: c.snapshot.Epoch,
457+ local: observation.Local,
458+ endpoint: observation.Endpoint,
459+ registration: observation.Registration,
460+ lifecycle: session.LifecycleID,
461+ name: session.Name,
462+ }
463+ rowKey := pickerSessionRowKey(ref)
464+ refs[rowKey] = ref
465+ selectable := eligible && session.State != catalogue.RemoteCatalogSessionBroken
466+ actions := protocol.PickerLineActions(0)
467+ if selectable {
468+ actions = protocol.PickerCanNavigate
469+ }
470+ reason := pickerObservationReason(observation, fresh)
471+ if reason == "" {
472+ reason = session.Reason
473+ }
474+ lines = append(lines, protocol.PickerLine{
475+ Key: rowKey,
476+ Kind: protocol.PickerLineSession,
477+ Label: session.Name,
478+ Detail: pickerSessionDetail(session),
479+ Status: pickerSessionStatus(session),
480+ StatusDetail: reason,
481+ Stopped: session.State == catalogue.RemoteCatalogSessionDown,
482+ Dim: !selectable,
483+ Focusable: true,
484+ Actions: actions,
485+ Ephemeral: session.Ephemeral,
486+ })
487+ }
488+ }
489+ c.moveRetiredLocked(refs)
490+ c.refs = refs
491+ c.lines = lines
492+ c.cursor = pickerCatalogueCursor(lines)
493+}
494+
495+// moveRetiredLocked keeps the last known ref of a key that left the current
496+// projection so a late selection can still be classified as gone or replaced.
497+func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) {
498+ for key, ref := range c.refs {
499+ if _, ok := next[key]; ok {
500+ continue
501+ }
502+ if _, exists := c.retired[key]; !exists {
503+ c.retiredOrder = append(c.retiredOrder, key)
504+ }
505+ c.retired[key] = ref
506+ }
507+ for len(c.retiredOrder) > pickerCatalogueMaxRetiredRefs {
508+ oldest := c.retiredOrder[0]
509+ c.retiredOrder = c.retiredOrder[1:]
510+ delete(c.retired, oldest)
511+ }
512+}
513+
514+func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
515+ if c.snapshot.Epoch == 0 {
516+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
517+ }
518+ if ref.epoch != 0 && ref.epoch != c.snapshot.Epoch {
519+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
520+ }
521+ host := c.hostForRefLocked(ref)
522+ if host == nil {
523+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
524+ }
525+ observation := host.observation
526+ if !ref.local && observation.Registration.Incarnation != ref.registration.Incarnation {
527+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}
528+ }
529+ // Availability is decided before compatibility: an unobserved or
530+ // unreachable daemon refuses as unavailable, never as a version mismatch.
531+ // Only the legacy incompatible availability state, or a reachable daemon
532+ // whose observed version differs from the policy, is incompatible.
533+ switch {
534+ case observation.Availability == domain.RemoteAvailabilityIncompatible:
535+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}
536+ case observation.Availability != domain.RemoteAvailabilityReachable:
537+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"}
538+ case observation.ProtocolVersion == 0:
539+ // Reachable but unobserved: still never reported as a version mismatch.
540+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"}
541+ case !pickerObservationCompatible(observation):
542+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}
543+ }
544+ if !c.freshLocked(observation, c.clock.Now()) {
545+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueStale, Text: "host observation is stale"}
546+ }
547+
548+ request := ports.BrokerOpenStreamRequest{
549+ Epoch: c.snapshot.Epoch,
550+ Purpose: ports.BrokerStreamAttachment,
551+ Local: ref.local,
552+ Connection: base.Connection,
553+ Stream: base.Stream,
554+ Registration: observation.Registration,
555+ Policy: observation.Policy,
556+ Env: append([]string(nil), base.Env...),
557+ }
558+ if !ref.local {
559+ request.Endpoint = observation.Endpoint
560+ }
561+ switch ref.kind {
562+ case pickerSelectionExact:
563+ session, ok := pickerFindSession(observation.Sessions, ref.lifecycle)
564+ if !ok {
565+ if pickerSessionReplaced(observation.Sessions, ref) {
566+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
567+ }
568+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
569+ }
570+ if session.State == catalogue.RemoteCatalogSessionBroken {
571+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}
572+ }
573+ request.Admission = ports.BrokerAdmissionExact
574+ request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
575+ case pickerSelectionCreateNamed:
576+ if err := domain.ValidateSessionName(ref.createName); err != nil {
577+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
578+ }
579+ request.Admission = ports.BrokerAdmissionCreateNamed
580+ request.Name = ref.createName
581+ case pickerSelectionCreateEphemeral:
582+ request.Admission = ports.BrokerAdmissionCreateEphemeral
583+ default:
584+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}
585+ }
586+ if err := request.Validate(); err != nil {
587+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}
588+ }
589+ return request, nil
590+}
591+
592+func (c *pickerCatalogue) hostForRefLocked(ref pickerSelectionRef) *pickerHostState {
593+ if ref.local {
594+ for _, host := range c.hosts {
595+ if host.observation.Local {
596+ return host
597+ }
598+ }
599+ return nil
600+ }
601+ return c.hostForEndpointLocked(ref.endpoint)
602+}
603+
604+func (c *pickerCatalogue) hostForEndpointLocked(endpoint string) *pickerHostState {
605+ for _, host := range c.hosts {
606+ if !host.observation.Local && host.observation.Endpoint == endpoint {
607+ return host
608+ }
609+ }
610+ return nil
611+}
612+
613+// freshLocked reports whether LastSuccess is inside the freshness window. An
614+// observation that never succeeded is never fresh.
615+func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, now time.Time) bool {
616+ if observation.LastSuccess.IsZero() {
617+ return false
618+ }
619+ return now.Sub(observation.LastSuccess) <= c.freshness
620+}
621+
622+// pickerHostDiagnostic is one bounded, presentation-safe catalogue observation
623+// the controller surfaces as a toast.
624+type pickerHostDiagnostic struct {
625+ Key string
626+ Origin string
627+ Status protocol.PickerLineStatus
628+ Message string
629+}
630+
631+// diagnostics reports every projected host that is not a clean, fresh,
632+// compatible, reachable daemon, so the controller can surface it.
633+func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
634+ if c == nil {
635+ return nil
636+ }
637+ c.mu.Lock()
638+ defer c.mu.Unlock()
639+ now := c.clock.Now()
640+ diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts))
641+ for _, key := range c.order {
642+ host, ok := c.hosts[key]
643+ if !ok {
644+ continue
645+ }
646+ observation := host.observation
647+ fresh := c.freshLocked(observation, now)
648+ if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking {
649+ continue
650+ }
651+ diagnostics = append(diagnostics, pickerHostDiagnostic{
652+ Key: pickerHostRowKey(observation),
653+ Origin: pickerOriginLabel(observation),
654+ Status: pickerObservationStatus(observation, fresh),
655+ Message: pickerObservationReason(observation, fresh),
656+ })
657+ }
658+ return diagnostics
659+}
660+
661+func pickerDaemonKey(local bool, endpoint string) string {
662+ if local {
663+ return "local"
664+ }
665+ return "remote:" + endpoint
666+}
667+
668+// pickerCatalogueOriginFallback is the fixed, bounded label shown when an
669+// observation carries no display origin. A raw endpoint is never a display
670+// label.
671+const pickerCatalogueOriginFallback = "remote"
672+
673+func pickerOriginLabel(observation ports.BrokerDaemonObservation) string {
674+ if observation.DisplayOrigin != "" {
675+ return observation.DisplayOrigin
676+ }
677+ if observation.Local {
678+ return "local"
679+ }
680+ return pickerCatalogueOriginFallback
681+}
682+
683+// pickerHostRowKey is the stable, opaque key of a daemon's status row. It is
684+// never a selection: a host row is focusable but not committable.
685+func pickerHostRowKey(observation ports.BrokerDaemonObservation) string {
686+ return pickerRowKey("h", observation.Local, observation.Endpoint, observation.Registration.Incarnation[:], nil)
687+}
688+
689+// pickerSessionRowKey is the stable, opaque key of one exact session. It is
690+// derived only from identity (endpoint, registration incarnation, session
691+// lifecycle), so a rename never changes it and a replaced lifecycle always
692+// does.
693+func pickerSessionRowKey(ref pickerSelectionRef) string {
694+ return pickerRowKey("s", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.lifecycle[:])
695+}
696+
697+func pickerRowKey(prefix string, local bool, endpoint string, incarnation, lifecycle []byte) string {
698+ digest := sha256.New()
699+ digest.Write([]byte("vev.picker."))
700+ digest.Write([]byte(prefix))
701+ digest.Write([]byte{0})
702+ if local {
703+ digest.Write([]byte("true"))
704+ } else {
705+ digest.Write([]byte("false"))
706+ }
707+ digest.Write([]byte{0})
708+ digest.Write([]byte(endpoint))
709+ digest.Write([]byte{0})
710+ digest.Write(incarnation)
711+ digest.Write([]byte{0})
712+ digest.Write(lifecycle)
713+ return prefix + base64.RawURLEncoding.EncodeToString(digest.Sum(nil)[:16])
714+}
715+
716+// pickerStableHostOrder keeps each host's first-seen position and appends new
717+// hosts in publication order, so an unrelated update never reshuffles rows.
718+func pickerStableHostOrder(previous []string, daemons []ports.BrokerDaemonObservation) []string {
719+ present := make(map[string]struct{}, len(daemons))
720+ published := make([]string, 0, len(daemons))
721+ for i := range daemons {
722+ key := pickerDaemonKey(daemons[i].Local, daemons[i].Endpoint)
723+ if _, ok := present[key]; ok {
724+ continue
725+ }
726+ present[key] = struct{}{}
727+ published = append(published, key)
728+ }
729+ order := make([]string, 0, len(published))
730+ seen := make(map[string]struct{}, len(published))
731+ for _, key := range previous {
732+ if _, ok := present[key]; !ok {
733+ continue
734+ }
735+ if _, ok := seen[key]; ok {
736+ continue
737+ }
738+ seen[key] = struct{}{}
739+ order = append(order, key)
740+ }
741+ for _, key := range published {
742+ if _, ok := seen[key]; ok {
743+ continue
744+ }
745+ seen[key] = struct{}{}
746+ order = append(order, key)
747+ }
748+ return order
749+}
750+
751+// pickerObservationCompatible reports whether the observed daemon can accept an
752+// attachment under its own configured policy. A version mismatch is derived by
753+// comparing the observation's ProtocolVersion against its Policy.ProtocolVersion,
754+// and only when the daemon was actually observed (ProtocolVersion != 0). An
755+// unobserved daemon carries no version and is never compatible; reachability is
756+// classified separately so an unobserved or unreachable daemon is never
757+// reported as a version mismatch.
758+func pickerObservationCompatible(observation ports.BrokerDaemonObservation) bool {
759+ return observation.ProtocolVersion != 0 && observation.ProtocolVersion == observation.Policy.ProtocolVersion
760+}
761+
762+// pickerObservationVersionMismatch reports whether a daemon that was actually
763+// observed (ProtocolVersion != 0) reports a protocol version that differs from
764+// its policy's required version. Reachability and observation are classified
765+// separately, so an unobserved daemon is never a version mismatch.
766+func pickerObservationVersionMismatch(observation ports.BrokerDaemonObservation) bool {
767+ return observation.ProtocolVersion != 0 && observation.ProtocolVersion != observation.Policy.ProtocolVersion
768+}
769+
770+// pickerSameIncarnation reports whether two observations describe the same
771+// daemon identity, so their observed state may be compared for monotonicity.
772+func pickerSameIncarnation(current, incoming ports.BrokerDaemonObservation) bool {
773+ return current.Incarnation == incoming.Incarnation
774+}
775+
776+// pickerObservationNewer reports whether an incoming observation is at least as
777+// new as the current one for the same daemon identity. A different incarnation
778+// is an identity replacement and always wins.
779+func pickerObservationNewer(incoming, current ports.BrokerDaemonObservation) bool {
780+ if incoming.Incarnation != current.Incarnation {
781+ return true
782+ }
783+ if incoming.FailureEpisode != current.FailureEpisode {
784+ return incoming.FailureEpisode > current.FailureEpisode
785+ }
786+ return !incoming.LastSuccess.Before(current.LastSuccess)
787+}
788+
789+// pickerMergeAuthority keeps the newer observed state while refreshing every
790+// field the registry stamps as configured authority.
791+func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports.BrokerDaemonObservation {
792+ merged := current
793+ merged.Local = incoming.Local
794+ merged.Endpoint = incoming.Endpoint
795+ merged.DisplayOrigin = incoming.DisplayOrigin
796+ merged.Rank = incoming.Rank
797+ merged.Registration = incoming.Registration
798+ merged.Policy = incoming.Policy
799+ return merged
800+}
801+
802+// pickerObservationStatus classifies one observation into a row badge. It
803+// mirrors the legacy daemon-served classifier (directorySessionReason):
804+// availability is decided first, so an unobserved (Availability Unknown) or
805+// unreachable daemon is never reported as a version mismatch. A version
806+// mismatch is derived only for a reachable observation whose observed
807+// ProtocolVersion differs from its policy's required version.
808+func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus {
809+ switch observation.Availability {
810+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed:
811+ return protocol.PickerLineStatusDown
812+ case domain.RemoteAvailabilityInvalidResponse:
813+ return protocol.PickerLineStatusError
814+ case domain.RemoteAvailabilityIncompatible:
815+ return protocol.PickerLineStatusVersion
816+ case domain.RemoteAvailabilityReachable:
817+ if pickerObservationVersionMismatch(observation) {
818+ return protocol.PickerLineStatusVersion
819+ }
820+ if observation.ProtocolVersion == 0 || !fresh {
821+ return protocol.PickerLineStatusStale
822+ }
823+ return protocol.PickerLineStatusUp
824+ default:
825+ // Availability Unknown (unobserved) and any out-of-range value are
826+ // never treated as compatible.
827+ return protocol.PickerLineStatusStale
828+ }
829+}
830+
831+// pickerObservationReason classifies one observation into a bounded display
832+// reason. As with the badge, availability is decided first; the version branch
833+// applies only to a reachable observation whose observed version differs from
834+// policy. Unobserved and unknown availability render as refreshing, matching
835+// the legacy daemon-served classifier.
836+func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string {
837+ switch observation.Availability {
838+ case domain.RemoteAvailabilityUnreachable:
839+ return domain.RemoteReasonHostUnreachable
840+ case domain.RemoteAvailabilityAuthFailed:
841+ return domain.RemoteReasonAuthFailure
842+ case domain.RemoteAvailabilityInvalidResponse:
843+ return domain.RemoteReasonMalformed
844+ case domain.RemoteAvailabilityIncompatible:
845+ return domain.RemoteReasonVersionMismatch
846+ case domain.RemoteAvailabilityReachable:
847+ if pickerObservationVersionMismatch(observation) {
848+ return domain.RemoteReasonVersionMismatch
849+ }
850+ if observation.ProtocolVersion == 0 {
851+ return domain.RemoteReasonRefreshing
852+ }
853+ if observation.Checking {
854+ return domain.RemoteReasonRefreshing
855+ }
856+ if !fresh {
857+ return domain.RemoteReasonCatalogStale
858+ }
859+ return ""
860+ default:
861+ // Availability Unknown is the unobserved state: it is still being
862+ // discovered, so it renders as refreshing rather than as a failure.
863+ return domain.RemoteReasonRefreshing
864+ }
865+}
866+
867+func pickerHostDetail(observation ports.BrokerDaemonObservation) string {
868+ if !observation.InventoryKnown {
869+ return "catalogue pending"
870+ }
871+ return fmt.Sprintf("%d sessions", len(observation.Sessions))
872+}
873+
874+func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {
875+ switch session.State {
876+ case catalogue.RemoteCatalogSessionUp:
877+ return protocol.PickerLineStatusUp
878+ case catalogue.RemoteCatalogSessionDown:
879+ return protocol.PickerLineStatusStopped
880+ case catalogue.RemoteCatalogSessionBroken:
881+ return protocol.PickerLineStatusError
882+ default:
883+ return protocol.PickerLineStatusNone
884+ }
885+}
886+
887+func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
888+ parts := make([]string, 0, 2)
889+ if session.Attached {
890+ parts = append(parts, "attached")
891+ }
892+ if count := len(session.Tabs); count > 0 {
893+ parts = append(parts, fmt.Sprintf("%d tabs", count))
894+ }
895+ return strings.Join(parts, " · ")
896+}
897+
898+// pickerCatalogueCursor hints the first actionable row, then the first
899+// inspectable row, so a fresh picker always has a cursor destination when one
900+// exists.
901+func pickerCatalogueCursor(lines []protocol.PickerLine) protocol.PickerCursor {
902+ for i, line := range lines {
903+ if line.Key != "" && line.Focusable && line.Actions != 0 {
904+ return protocol.PickerCursor{Key: line.Key, Index: i}
905+ }
906+ }
907+ for i, line := range lines {
908+ if line.Key != "" && line.Focusable {
909+ return protocol.PickerCursor{Key: line.Key, Index: i}
910+ }
911+ }
912+ return protocol.PickerCursor{Index: -1}
913+}
914+
915+func pickerFindSession(sessions []catalogue.RemoteCatalogSession, lifecycle domain.SessionLifecycleID) (catalogue.RemoteCatalogSession, bool) {
916+ for _, session := range sessions {
917+ if session.LifecycleID == lifecycle {
918+ return session, true
919+ }
920+ }
921+ return catalogue.RemoteCatalogSession{}, false
922+}
923+
924+// pickerSessionReplaced reports whether the exact lifecycle vanished but a
925+// same-name session remains, which is a replacement rather than a removal.
926+func pickerSessionReplaced(sessions []catalogue.RemoteCatalogSession, ref pickerSelectionRef) bool {
927+ if ref.name == "" {
928+ return false
929+ }
930+ for _, session := range sessions {
931+ if session.LifecycleID != ref.lifecycle && session.Name == ref.name {
932+ return true
933+ }
934+ }
935+ return false
936+}
@@ -0,0 +1,936 @@
1+package client
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/base64"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+)
17+
18+// Client-owned picker catalogue (Plan 001 P5.2b, offline and unactivated).
19+//
20+// The autonomous supervisor owns the broker connection and the single terminal
21+// input lifetime; this file owns the catalogue the picker presents, projected
22+// from the broker's own observations rather than from a serving-daemon
23+// catalogue. It is the P5.2b replacement source for the daemon-served
24+// protocol.PickerSnapshot: rows are derived from ports.BrokerSnapshot, the
25+// opaque keys stay client-local, and a selection resolves to an exact
26+// ports.BrokerOpenStreamRequest instead of a daemon-side key handoff.
27+//
28+// The projection preserves configured authority and observed state separately,
29+// exactly as ports.BrokerDaemonObservation does:
30+//
31+// - daemon identity is the local flag or the remote endpoint plus the
32+// registration incarnation; a re-registered endpoint is an identity
33+// replacement and its rows never survive.
34+// - session identity is the catalogue lifecycle ID; a same-name replacement
35+// resolves as "replaced", never by name.
36+// - freshness is the LastSuccess window against the injected clock.
37+// - compatibility is the observed protocol version against the policy's
38+// required version.
39+// - availability is the observed RemoteAvailability.
40+//
41+// Updates are monotonic: a publication that does not supersede the applied
42+// epoch/revision is ignored, and a per-daemon observation that regresses
43+// (older LastSuccess and no newer failure episode, same incarnation) keeps the
44+// newer observed state while still refreshing configured authority. Row order
45+// is stable: hosts keep their first-seen position and unrelated updates never
46+// reshuffle them. Nothing here attaches to a daemon, starts one, or writes a
47+// session.
48+
49+const (
50+ // pickerCatalogueSourceID names the single client-owned picker source.
51+ pickerCatalogueSourceID = "broker"
52+ // pickerCatalogueDefaultFreshness bounds how old a LastSuccess may be
53+ // before a row is displayed as stale and refused at resolution.
54+ pickerCatalogueDefaultFreshness = 30 * time.Second
55+ // pickerCatalogueMaxRetiredRefs bounds the retired-key fence used to
56+ // distinguish "gone" from "replaced" after a row leaves the projection.
57+ pickerCatalogueMaxRetiredRefs = 128
58+)
59+
60+// pickerCatalogueErrorCode is the closed refusal taxonomy for a client-owned
61+// picker selection. The zero value is never produced: a refusal always names
62+// why the exact selection cannot be resolved.
63+type pickerCatalogueErrorCode uint8
64+
65+const (
66+ pickerCatalogueUnknown pickerCatalogueErrorCode = iota + 1
67+ pickerCatalogueGone
68+ pickerCatalogueReplaced
69+ pickerCatalogueStale
70+ pickerCatalogueIncompatible
71+ pickerCatalogueUnavailable
72+ pickerCatalogueEpochStale
73+ pickerCatalogueInvalidName
74+ pickerCatalogueNoSelection
75+)
76+
77+func (c pickerCatalogueErrorCode) String() string {
78+ switch c {
79+ case pickerCatalogueUnknown:
80+ return "unknown"
81+ case pickerCatalogueGone:
82+ return "gone"
83+ case pickerCatalogueReplaced:
84+ return "replaced"
85+ case pickerCatalogueStale:
86+ return "stale"
87+ case pickerCatalogueIncompatible:
88+ return "incompatible"
89+ case pickerCatalogueUnavailable:
90+ return "unavailable"
91+ case pickerCatalogueEpochStale:
92+ return "stale_epoch"
93+ case pickerCatalogueInvalidName:
94+ return "invalid_name"
95+ case pickerCatalogueNoSelection:
96+ return "no_selection"
97+ default:
98+ return "unknown"
99+ }
100+}
101+
102+// pickerCatalogueError is a typed, bounded refusal. It never carries a raw
103+// endpoint, credential, or adapter diagnostic as display text.
104+type pickerCatalogueError struct {
105+ Code pickerCatalogueErrorCode
106+ Text string
107+}
108+
109+func (e pickerCatalogueError) Error() string {
110+ if e.Text == "" {
111+ return "vev: picker selection " + e.Code.String()
112+ }
113+ return "vev: picker selection " + e.Code.String() + ": " + e.Text
114+}
115+
116+// pickerCatalogueErrorIs reports whether err is a typed catalogue refusal with
117+// the supplied code, so callers classify without matching message text.
118+func pickerCatalogueErrorIs(err error, code pickerCatalogueErrorCode) bool {
119+ var typed pickerCatalogueError
120+ return errors.As(err, &typed) && typed.Code == code
121+}
122+
123+// pickerSelectionKind names one closed admission variant a resolved selection
124+// can carry.
125+type pickerSelectionKind uint8
126+
127+const (
128+ // pickerSelectionExact attaches to one exact session lifecycle.
129+ pickerSelectionExact pickerSelectionKind = iota + 1
130+ // pickerSelectionCreateNamed creates one named session.
131+ pickerSelectionCreateNamed
132+ // pickerSelectionCreateEphemeral creates one ephemeral session.
133+ pickerSelectionCreateEphemeral
134+)
135+
136+// pickerSelectionRef is the client's exact selection identity, captured when a
137+// row was projected. Resolution revalidates it against the latest snapshot; it
138+// is never reconstructed from a display label.
139+type pickerSelectionRef struct {
140+ kind pickerSelectionKind
141+ epoch ports.BrokerEpoch
142+ local bool
143+ endpoint string
144+ registration domain.RemoteRegistration
145+ lifecycle domain.SessionLifecycleID
146+ name string
147+ createName string
148+}
149+
150+// pickerResolveBase carries the live broker connection identity a resolved
151+// request must be fenced to. The catalogue supplies everything else.
152+type pickerResolveBase struct {
153+ Connection ports.BrokerConnectionID
154+ Stream ports.BrokerStreamID
155+ Env []string
156+}
157+
158+// pickerHostState is one projected daemon and its newest accepted
159+// observation.
160+type pickerHostState struct {
161+ observation ports.BrokerDaemonObservation
162+}
163+
164+// pickerCatalogueConfig supplies the fresh-window clock. Clock is optional and
165+// falls back to the system clock; Freshness defaults to
166+// pickerCatalogueDefaultFreshness.
167+type pickerCatalogueConfig struct {
168+ Clock ports.Clock
169+ Freshness time.Duration
170+}
171+
172+// pickerCatalogue is the client-owned, monotonic projection of broker daemon
173+// observations into ordered picker rows. It is safe for concurrent use: the
174+// supervisor applies publications from its run path while the picker loop
175+// resolves selections.
176+type pickerCatalogue struct {
177+ clock ports.Clock
178+ freshness time.Duration
179+
180+ mu sync.Mutex
181+ snapshot ports.BrokerSnapshot
182+ hosts map[string]*pickerHostState
183+ order []string
184+ refs map[string]pickerSelectionRef
185+ retired map[string]pickerSelectionRef
186+ retiredOrder []string
187+ lines []protocol.PickerLine
188+ cursor protocol.PickerCursor
189+}
190+
191+// newPickerCatalogue builds an empty projection.
192+func newPickerCatalogue(cfg pickerCatalogueConfig) *pickerCatalogue {
193+ clock := cfg.Clock
194+ if supervisorNil(clock) {
195+ clock = systemClock{}
196+ }
197+ freshness := cfg.Freshness
198+ if freshness <= 0 {
199+ freshness = pickerCatalogueDefaultFreshness
200+ }
201+ return &pickerCatalogue{
202+ clock: clock,
203+ freshness: freshness,
204+ hosts: make(map[string]*pickerHostState),
205+ refs: make(map[string]pickerSelectionRef),
206+ retired: make(map[string]pickerSelectionRef),
207+ }
208+}
209+
210+// Apply folds one broker publication into the projection. It reports whether
211+// the projection changed. The publication is the projection's trust boundary:
212+// a snapshot that fails its own contract is rejected outright and the previous
213+// projection is kept. A valid publication that does not supersede the applied
214+// epoch/revision is also ignored, so a slow or out-of-order snapshot can never
215+// regress a row.
216+func (c *pickerCatalogue) Apply(snapshot ports.BrokerSnapshot) bool {
217+ if c == nil {
218+ return false
219+ }
220+ c.mu.Lock()
221+ defer c.mu.Unlock()
222+ if snapshot.Epoch == 0 || snapshot.Revision == 0 {
223+ return false
224+ }
225+ if err := snapshot.Validate(); err != nil {
226+ return false
227+ }
228+ if !snapshot.Supersedes(c.snapshot) {
229+ return false
230+ }
231+ if c.snapshot.Epoch != 0 && snapshot.Epoch != c.snapshot.Epoch {
232+ // A new broker process is a full resynchronization: every key, row, and
233+ // retired fence from the previous epoch is invalid.
234+ c.resetLocked()
235+ }
236+ c.snapshot = snapshot.Clone()
237+ c.mergeHostsLocked()
238+ c.projectLocked()
239+ return true
240+}
241+
242+// Epoch reports the applied broker epoch, or zero before the first publication.
243+func (c *pickerCatalogue) Epoch() ports.BrokerEpoch {
244+ if c == nil {
245+ return 0
246+ }
247+ c.mu.Lock()
248+ defer c.mu.Unlock()
249+ return c.snapshot.Epoch
250+}
251+
252+// Revision reports the applied broker revision, or zero before the first
253+// publication.
254+func (c *pickerCatalogue) Revision() ports.BrokerRevision {
255+ if c == nil {
256+ return 0
257+ }
258+ c.mu.Lock()
259+ defer c.mu.Unlock()
260+ return c.snapshot.Revision
261+}
262+
263+// Snapshot returns a defensive copy of the applied broker snapshot.
264+func (c *pickerCatalogue) Snapshot() ports.BrokerSnapshot {
265+ if c == nil {
266+ return ports.BrokerSnapshot{}
267+ }
268+ c.mu.Lock()
269+ defer c.mu.Unlock()
270+ return c.snapshot.Clone()
271+}
272+
273+// Lines returns a copy of the current ordered rows.
274+func (c *pickerCatalogue) Lines() []protocol.PickerLine {
275+ if c == nil {
276+ return nil
277+ }
278+ c.mu.Lock()
279+ defer c.mu.Unlock()
280+ return append([]protocol.PickerLine(nil), c.lines...)
281+}
282+
283+// Cursor returns the projection's cursor hint.
284+func (c *pickerCatalogue) Cursor() protocol.PickerCursor {
285+ if c == nil {
286+ return protocol.PickerCursor{}
287+ }
288+ c.mu.Lock()
289+ defer c.mu.Unlock()
290+ return c.cursor
291+}
292+
293+// projection returns the current rows and cursor hint together.
294+func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) {
295+ if c == nil {
296+ return nil, protocol.PickerCursor{}
297+ }
298+ c.mu.Lock()
299+ defer c.mu.Unlock()
300+ return append([]protocol.PickerLine(nil), c.lines...), c.cursor
301+}
302+
303+// Ref returns the exact selection identity recorded for one row key, including
304+// a retired key that has left the current projection.
305+func (c *pickerCatalogue) Ref(key string) (pickerSelectionRef, bool) {
306+ if c == nil {
307+ return pickerSelectionRef{}, false
308+ }
309+ c.mu.Lock()
310+ defer c.mu.Unlock()
311+ if ref, ok := c.refs[key]; ok {
312+ return ref, true
313+ }
314+ ref, ok := c.retired[key]
315+ return ref, ok
316+}
317+
318+// Resolve revalidates one row key against the LATEST applied snapshot and
319+// returns the exact broker stream request it names. It refuses with a typed
320+// refusal when the identity is gone, replaced, stale, incompatible, or
321+// unavailable, and it never invents identity.
322+func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
323+ if c == nil {
324+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
325+ }
326+ c.mu.Lock()
327+ defer c.mu.Unlock()
328+ ref, ok := c.refs[key]
329+ if !ok {
330+ ref, ok = c.retired[key]
331+ }
332+ if !ok {
333+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"}
334+ }
335+ return c.resolveRefLocked(ref, base)
336+}
337+
338+// ResolveRef revalidates an exact selection identity a driver captured when it
339+// displayed the row. It is the entry point for a selection held across a
340+// broker epoch or an equivalent output boundary, where the key alone would be
341+// re-bound to a newer identity.
342+func (c *pickerCatalogue) ResolveRef(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
343+ if c == nil {
344+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
345+ }
346+ c.mu.Lock()
347+ defer c.mu.Unlock()
348+ return c.resolveRefLocked(ref, base)
349+}
350+
351+// ResolveCreation revalidates a named or ephemeral creation against the latest
352+// snapshot. The created identity is not invented: the daemon must be present,
353+// compatible, available, and fresh, and a named creation needs a valid session
354+// name.
355+func (c *pickerCatalogue) ResolveCreation(local bool, endpoint string, kind pickerSelectionKind, name string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
356+ if c == nil {
357+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"}
358+ }
359+ if kind != pickerSelectionCreateNamed && kind != pickerSelectionCreateEphemeral {
360+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "not a creation selection"}
361+ }
362+ c.mu.Lock()
363+ defer c.mu.Unlock()
364+ ref := pickerSelectionRef{kind: kind, epoch: c.snapshot.Epoch, local: local, endpoint: endpoint, createName: name}
365+ if !local {
366+ host := c.hostForEndpointLocked(endpoint)
367+ if host == nil {
368+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
369+ }
370+ ref.registration = host.observation.Registration
371+ }
372+ return c.resolveRefLocked(ref, base)
373+}
374+
375+func (c *pickerCatalogue) resetLocked() {
376+ c.hosts = make(map[string]*pickerHostState)
377+ c.order = nil
378+ c.refs = make(map[string]pickerSelectionRef)
379+ c.retired = make(map[string]pickerSelectionRef)
380+ c.retiredOrder = nil
381+ c.lines = nil
382+ c.cursor = protocol.PickerCursor{}
383+}
384+
385+// mergeHostsLocked folds the newest publication into the per-daemon state. A
386+// daemon keeps its observed state when the incoming observation regresses, and
387+// an incarnation change replaces the daemon's identity wholesale.
388+func (c *pickerCatalogue) mergeHostsLocked() {
389+ next := make(map[string]*pickerHostState, len(c.snapshot.Daemons))
390+ for i := range c.snapshot.Daemons {
391+ observation := c.snapshot.Daemons[i]
392+ key := pickerDaemonKey(observation.Local, observation.Endpoint)
393+ current, ok := c.hosts[key]
394+ if ok && pickerSameIncarnation(current.observation, observation) && !pickerObservationNewer(observation, current.observation) {
395+ // The publication is newer but this daemon's own observation is not:
396+ // keep the newer observed state and still apply configured authority.
397+ next[key] = &pickerHostState{
398+ observation: pickerMergeAuthority(current.observation, observation),
399+ }
400+ continue
401+ }
402+ next[key] = &pickerHostState{observation: observation}
403+ }
404+ c.hosts = next
405+ c.order = pickerStableHostOrder(c.order, c.snapshot.Daemons)
406+ // Tombstones fence a retired registration even if a publication still
407+ // carries it; a re-added endpoint with a fresh incarnation is never fenced.
408+ for _, tombstone := range c.snapshot.Removed {
409+ for key, host := range c.hosts {
410+ if !host.observation.Local && host.observation.Endpoint == tombstone.Endpoint && tombstone.Fences(host.observation.Registration) {
411+ delete(c.hosts, key)
412+ }
413+ }
414+ }
415+}
416+
417+// projectLocked rebuilds the ordered rows and their opaque keys from the
418+// current host state. It is deterministic: the same inputs always produce the
419+// same keys, order, and actions.
420+func (c *pickerCatalogue) projectLocked() {
421+ now := c.clock.Now()
422+ refs := make(map[string]pickerSelectionRef)
423+ lines := make([]protocol.PickerLine, 0, len(c.hosts)*2)
424+ for _, key := range c.order {
425+ host, ok := c.hosts[key]
426+ if !ok {
427+ continue
428+ }
429+ observation := host.observation
430+ fresh := c.freshLocked(observation, now)
431+ compatible := pickerObservationCompatible(observation)
432+ available := observation.Availability == domain.RemoteAvailabilityReachable
433+ eligible := compatible && available && fresh
434+ origin := pickerOriginLabel(observation)
435+
436+ lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true})
437+ lines = append(lines, protocol.PickerLine{
438+ Key: pickerHostRowKey(observation),
439+ Kind: protocol.PickerLineHost,
440+ Label: origin,
441+ Detail: pickerHostDetail(observation),
442+ Status: pickerObservationStatus(observation, fresh),
443+ StatusDetail: pickerObservationReason(observation, fresh),
444+ Focusable: true,
445+ })
446+
447+ for i := range observation.Sessions {
448+ session := observation.Sessions[i]
449+ if session.LifecycleID == (domain.SessionLifecycleID{}) {
450+ // A session without a lifecycle cannot be addressed exactly and
451+ // is never invented into one.
452+ continue
453+ }
454+ ref := pickerSelectionRef{
455+ kind: pickerSelectionExact,
456+ epoch: c.snapshot.Epoch,
457+ local: observation.Local,
458+ endpoint: observation.Endpoint,
459+ registration: observation.Registration,
460+ lifecycle: session.LifecycleID,
461+ name: session.Name,
462+ }
463+ rowKey := pickerSessionRowKey(ref)
464+ refs[rowKey] = ref
465+ selectable := eligible && session.State != catalogue.RemoteCatalogSessionBroken
466+ actions := protocol.PickerLineActions(0)
467+ if selectable {
468+ actions = protocol.PickerCanNavigate
469+ }
470+ reason := pickerObservationReason(observation, fresh)
471+ if reason == "" {
472+ reason = session.Reason
473+ }
474+ lines = append(lines, protocol.PickerLine{
475+ Key: rowKey,
476+ Kind: protocol.PickerLineSession,
477+ Label: session.Name,
478+ Detail: pickerSessionDetail(session),
479+ Status: pickerSessionStatus(session),
480+ StatusDetail: reason,
481+ Stopped: session.State == catalogue.RemoteCatalogSessionDown,
482+ Dim: !selectable,
483+ Focusable: true,
484+ Actions: actions,
485+ Ephemeral: session.Ephemeral,
486+ })
487+ }
488+ }
489+ c.moveRetiredLocked(refs)
490+ c.refs = refs
491+ c.lines = lines
492+ c.cursor = pickerCatalogueCursor(lines)
493+}
494+
495+// moveRetiredLocked keeps the last known ref of a key that left the current
496+// projection so a late selection can still be classified as gone or replaced.
497+func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) {
498+ for key, ref := range c.refs {
499+ if _, ok := next[key]; ok {
500+ continue
501+ }
502+ if _, exists := c.retired[key]; !exists {
503+ c.retiredOrder = append(c.retiredOrder, key)
504+ }
505+ c.retired[key] = ref
506+ }
507+ for len(c.retiredOrder) > pickerCatalogueMaxRetiredRefs {
508+ oldest := c.retiredOrder[0]
509+ c.retiredOrder = c.retiredOrder[1:]
510+ delete(c.retired, oldest)
511+ }
512+}
513+
514+func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
515+ if c.snapshot.Epoch == 0 {
516+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
517+ }
518+ if ref.epoch != 0 && ref.epoch != c.snapshot.Epoch {
519+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"}
520+ }
521+ host := c.hostForRefLocked(ref)
522+ if host == nil {
523+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"}
524+ }
525+ observation := host.observation
526+ if !ref.local && observation.Registration.Incarnation != ref.registration.Incarnation {
527+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"}
528+ }
529+ // Availability is decided before compatibility: an unobserved or
530+ // unreachable daemon refuses as unavailable, never as a version mismatch.
531+ // Only the legacy incompatible availability state, or a reachable daemon
532+ // whose observed version differs from the policy, is incompatible.
533+ switch {
534+ case observation.Availability == domain.RemoteAvailabilityIncompatible:
535+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}
536+ case observation.Availability != domain.RemoteAvailabilityReachable:
537+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"}
538+ case observation.ProtocolVersion == 0:
539+ // Reachable but unobserved: still never reported as a version mismatch.
540+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"}
541+ case !pickerObservationCompatible(observation):
542+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"}
543+ }
544+ if !c.freshLocked(observation, c.clock.Now()) {
545+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueStale, Text: "host observation is stale"}
546+ }
547+
548+ request := ports.BrokerOpenStreamRequest{
549+ Epoch: c.snapshot.Epoch,
550+ Purpose: ports.BrokerStreamAttachment,
551+ Local: ref.local,
552+ Connection: base.Connection,
553+ Stream: base.Stream,
554+ Registration: observation.Registration,
555+ Policy: observation.Policy,
556+ Env: append([]string(nil), base.Env...),
557+ }
558+ if !ref.local {
559+ request.Endpoint = observation.Endpoint
560+ }
561+ switch ref.kind {
562+ case pickerSelectionExact:
563+ session, ok := pickerFindSession(observation.Sessions, ref.lifecycle)
564+ if !ok {
565+ if pickerSessionReplaced(observation.Sessions, ref) {
566+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"}
567+ }
568+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"}
569+ }
570+ if session.State == catalogue.RemoteCatalogSessionBroken {
571+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"}
572+ }
573+ request.Admission = ports.BrokerAdmissionExact
574+ request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
575+ case pickerSelectionCreateNamed:
576+ if err := domain.ValidateSessionName(ref.createName); err != nil {
577+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"}
578+ }
579+ request.Admission = ports.BrokerAdmissionCreateNamed
580+ request.Name = ref.createName
581+ case pickerSelectionCreateEphemeral:
582+ request.Admission = ports.BrokerAdmissionCreateEphemeral
583+ default:
584+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"}
585+ }
586+ if err := request.Validate(); err != nil {
587+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"}
588+ }
589+ return request, nil
590+}
591+
592+func (c *pickerCatalogue) hostForRefLocked(ref pickerSelectionRef) *pickerHostState {
593+ if ref.local {
594+ for _, host := range c.hosts {
595+ if host.observation.Local {
596+ return host
597+ }
598+ }
599+ return nil
600+ }
601+ return c.hostForEndpointLocked(ref.endpoint)
602+}
603+
604+func (c *pickerCatalogue) hostForEndpointLocked(endpoint string) *pickerHostState {
605+ for _, host := range c.hosts {
606+ if !host.observation.Local && host.observation.Endpoint == endpoint {
607+ return host
608+ }
609+ }
610+ return nil
611+}
612+
613+// freshLocked reports whether LastSuccess is inside the freshness window. An
614+// observation that never succeeded is never fresh.
615+func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, now time.Time) bool {
616+ if observation.LastSuccess.IsZero() {
617+ return false
618+ }
619+ return now.Sub(observation.LastSuccess) <= c.freshness
620+}
621+
622+// pickerHostDiagnostic is one bounded, presentation-safe catalogue observation
623+// the controller surfaces as a toast.
624+type pickerHostDiagnostic struct {
625+ Key string
626+ Origin string
627+ Status protocol.PickerLineStatus
628+ Message string
629+}
630+
631+// diagnostics reports every projected host that is not a clean, fresh,
632+// compatible, reachable daemon, so the controller can surface it.
633+func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic {
634+ if c == nil {
635+ return nil
636+ }
637+ c.mu.Lock()
638+ defer c.mu.Unlock()
639+ now := c.clock.Now()
640+ diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts))
641+ for _, key := range c.order {
642+ host, ok := c.hosts[key]
643+ if !ok {
644+ continue
645+ }
646+ observation := host.observation
647+ fresh := c.freshLocked(observation, now)
648+ if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking {
649+ continue
650+ }
651+ diagnostics = append(diagnostics, pickerHostDiagnostic{
652+ Key: pickerHostRowKey(observation),
653+ Origin: pickerOriginLabel(observation),
654+ Status: pickerObservationStatus(observation, fresh),
655+ Message: pickerObservationReason(observation, fresh),
656+ })
657+ }
658+ return diagnostics
659+}
660+
661+func pickerDaemonKey(local bool, endpoint string) string {
662+ if local {
663+ return "local"
664+ }
665+ return "remote:" + endpoint
666+}
667+
668+// pickerCatalogueOriginFallback is the fixed, bounded label shown when an
669+// observation carries no display origin. A raw endpoint is never a display
670+// label.
671+const pickerCatalogueOriginFallback = "remote"
672+
673+func pickerOriginLabel(observation ports.BrokerDaemonObservation) string {
674+ if observation.DisplayOrigin != "" {
675+ return observation.DisplayOrigin
676+ }
677+ if observation.Local {
678+ return "local"
679+ }
680+ return pickerCatalogueOriginFallback
681+}
682+
683+// pickerHostRowKey is the stable, opaque key of a daemon's status row. It is
684+// never a selection: a host row is focusable but not committable.
685+func pickerHostRowKey(observation ports.BrokerDaemonObservation) string {
686+ return pickerRowKey("h", observation.Local, observation.Endpoint, observation.Registration.Incarnation[:], nil)
687+}
688+
689+// pickerSessionRowKey is the stable, opaque key of one exact session. It is
690+// derived only from identity (endpoint, registration incarnation, session
691+// lifecycle), so a rename never changes it and a replaced lifecycle always
692+// does.
693+func pickerSessionRowKey(ref pickerSelectionRef) string {
694+ return pickerRowKey("s", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.lifecycle[:])
695+}
696+
697+func pickerRowKey(prefix string, local bool, endpoint string, incarnation, lifecycle []byte) string {
698+ digest := sha256.New()
699+ digest.Write([]byte("vev.picker."))
700+ digest.Write([]byte(prefix))
701+ digest.Write([]byte{0})
702+ if local {
703+ digest.Write([]byte("true"))
704+ } else {
705+ digest.Write([]byte("false"))
706+ }
707+ digest.Write([]byte{0})
708+ digest.Write([]byte(endpoint))
709+ digest.Write([]byte{0})
710+ digest.Write(incarnation)
711+ digest.Write([]byte{0})
712+ digest.Write(lifecycle)
713+ return prefix + base64.RawURLEncoding.EncodeToString(digest.Sum(nil)[:16])
714+}
715+
716+// pickerStableHostOrder keeps each host's first-seen position and appends new
717+// hosts in publication order, so an unrelated update never reshuffles rows.
718+func pickerStableHostOrder(previous []string, daemons []ports.BrokerDaemonObservation) []string {
719+ present := make(map[string]struct{}, len(daemons))
720+ published := make([]string, 0, len(daemons))
721+ for i := range daemons {
722+ key := pickerDaemonKey(daemons[i].Local, daemons[i].Endpoint)
723+ if _, ok := present[key]; ok {
724+ continue
725+ }
726+ present[key] = struct{}{}
727+ published = append(published, key)
728+ }
729+ order := make([]string, 0, len(published))
730+ seen := make(map[string]struct{}, len(published))
731+ for _, key := range previous {
732+ if _, ok := present[key]; !ok {
733+ continue
734+ }
735+ if _, ok := seen[key]; ok {
736+ continue
737+ }
738+ seen[key] = struct{}{}
739+ order = append(order, key)
740+ }
741+ for _, key := range published {
742+ if _, ok := seen[key]; ok {
743+ continue
744+ }
745+ seen[key] = struct{}{}
746+ order = append(order, key)
747+ }
748+ return order
749+}
750+
751+// pickerObservationCompatible reports whether the observed daemon can accept an
752+// attachment under its own configured policy. A version mismatch is derived by
753+// comparing the observation's ProtocolVersion against its Policy.ProtocolVersion,
754+// and only when the daemon was actually observed (ProtocolVersion != 0). An
755+// unobserved daemon carries no version and is never compatible; reachability is
756+// classified separately so an unobserved or unreachable daemon is never
757+// reported as a version mismatch.
758+func pickerObservationCompatible(observation ports.BrokerDaemonObservation) bool {
759+ return observation.ProtocolVersion != 0 && observation.ProtocolVersion == observation.Policy.ProtocolVersion
760+}
761+
762+// pickerObservationVersionMismatch reports whether a daemon that was actually
763+// observed (ProtocolVersion != 0) reports a protocol version that differs from
764+// its policy's required version. Reachability and observation are classified
765+// separately, so an unobserved daemon is never a version mismatch.
766+func pickerObservationVersionMismatch(observation ports.BrokerDaemonObservation) bool {
767+ return observation.ProtocolVersion != 0 && observation.ProtocolVersion != observation.Policy.ProtocolVersion
768+}
769+
770+// pickerSameIncarnation reports whether two observations describe the same
771+// daemon identity, so their observed state may be compared for monotonicity.
772+func pickerSameIncarnation(current, incoming ports.BrokerDaemonObservation) bool {
773+ return current.Incarnation == incoming.Incarnation
774+}
775+
776+// pickerObservationNewer reports whether an incoming observation is at least as
777+// new as the current one for the same daemon identity. A different incarnation
778+// is an identity replacement and always wins.
779+func pickerObservationNewer(incoming, current ports.BrokerDaemonObservation) bool {
780+ if incoming.Incarnation != current.Incarnation {
781+ return true
782+ }
783+ if incoming.FailureEpisode != current.FailureEpisode {
784+ return incoming.FailureEpisode > current.FailureEpisode
785+ }
786+ return !incoming.LastSuccess.Before(current.LastSuccess)
787+}
788+
789+// pickerMergeAuthority keeps the newer observed state while refreshing every
790+// field the registry stamps as configured authority.
791+func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports.BrokerDaemonObservation {
792+ merged := current
793+ merged.Local = incoming.Local
794+ merged.Endpoint = incoming.Endpoint
795+ merged.DisplayOrigin = incoming.DisplayOrigin
796+ merged.Rank = incoming.Rank
797+ merged.Registration = incoming.Registration
798+ merged.Policy = incoming.Policy
799+ return merged
800+}
801+
802+// pickerObservationStatus classifies one observation into a row badge. It
803+// mirrors the legacy daemon-served classifier (directorySessionReason):
804+// availability is decided first, so an unobserved (Availability Unknown) or
805+// unreachable daemon is never reported as a version mismatch. A version
806+// mismatch is derived only for a reachable observation whose observed
807+// ProtocolVersion differs from its policy's required version.
808+func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus {
809+ switch observation.Availability {
810+ case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed:
811+ return protocol.PickerLineStatusDown
812+ case domain.RemoteAvailabilityInvalidResponse:
813+ return protocol.PickerLineStatusError
814+ case domain.RemoteAvailabilityIncompatible:
815+ return protocol.PickerLineStatusVersion
816+ case domain.RemoteAvailabilityReachable:
817+ if pickerObservationVersionMismatch(observation) {
818+ return protocol.PickerLineStatusVersion
819+ }
820+ if observation.ProtocolVersion == 0 || !fresh {
821+ return protocol.PickerLineStatusStale
822+ }
823+ return protocol.PickerLineStatusUp
824+ default:
825+ // Availability Unknown (unobserved) and any out-of-range value are
826+ // never treated as compatible.
827+ return protocol.PickerLineStatusStale
828+ }
829+}
830+
831+// pickerObservationReason classifies one observation into a bounded display
832+// reason. As with the badge, availability is decided first; the version branch
833+// applies only to a reachable observation whose observed version differs from
834+// policy. Unobserved and unknown availability render as refreshing, matching
835+// the legacy daemon-served classifier.
836+func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string {
837+ switch observation.Availability {
838+ case domain.RemoteAvailabilityUnreachable:
839+ return domain.RemoteReasonHostUnreachable
840+ case domain.RemoteAvailabilityAuthFailed:
841+ return domain.RemoteReasonAuthFailure
842+ case domain.RemoteAvailabilityInvalidResponse:
843+ return domain.RemoteReasonMalformed
844+ case domain.RemoteAvailabilityIncompatible:
845+ return domain.RemoteReasonVersionMismatch
846+ case domain.RemoteAvailabilityReachable:
847+ if pickerObservationVersionMismatch(observation) {
848+ return domain.RemoteReasonVersionMismatch
849+ }
850+ if observation.ProtocolVersion == 0 {
851+ return domain.RemoteReasonRefreshing
852+ }
853+ if observation.Checking {
854+ return domain.RemoteReasonRefreshing
855+ }
856+ if !fresh {
857+ return domain.RemoteReasonCatalogStale
858+ }
859+ return ""
860+ default:
861+ // Availability Unknown is the unobserved state: it is still being
862+ // discovered, so it renders as refreshing rather than as a failure.
863+ return domain.RemoteReasonRefreshing
864+ }
865+}
866+
867+func pickerHostDetail(observation ports.BrokerDaemonObservation) string {
868+ if !observation.InventoryKnown {
869+ return "catalogue pending"
870+ }
871+ return fmt.Sprintf("%d sessions", len(observation.Sessions))
872+}
873+
874+func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus {
875+ switch session.State {
876+ case catalogue.RemoteCatalogSessionUp:
877+ return protocol.PickerLineStatusUp
878+ case catalogue.RemoteCatalogSessionDown:
879+ return protocol.PickerLineStatusStopped
880+ case catalogue.RemoteCatalogSessionBroken:
881+ return protocol.PickerLineStatusError
882+ default:
883+ return protocol.PickerLineStatusNone
884+ }
885+}
886+
887+func pickerSessionDetail(session catalogue.RemoteCatalogSession) string {
888+ parts := make([]string, 0, 2)
889+ if session.Attached {
890+ parts = append(parts, "attached")
891+ }
892+ if count := len(session.Tabs); count > 0 {
893+ parts = append(parts, fmt.Sprintf("%d tabs", count))
894+ }
895+ return strings.Join(parts, " · ")
896+}
897+
898+// pickerCatalogueCursor hints the first actionable row, then the first
899+// inspectable row, so a fresh picker always has a cursor destination when one
900+// exists.
901+func pickerCatalogueCursor(lines []protocol.PickerLine) protocol.PickerCursor {
902+ for i, line := range lines {
903+ if line.Key != "" && line.Focusable && line.Actions != 0 {
904+ return protocol.PickerCursor{Key: line.Key, Index: i}
905+ }
906+ }
907+ for i, line := range lines {
908+ if line.Key != "" && line.Focusable {
909+ return protocol.PickerCursor{Key: line.Key, Index: i}
910+ }
911+ }
912+ return protocol.PickerCursor{Index: -1}
913+}
914+
915+func pickerFindSession(sessions []catalogue.RemoteCatalogSession, lifecycle domain.SessionLifecycleID) (catalogue.RemoteCatalogSession, bool) {
916+ for _, session := range sessions {
917+ if session.LifecycleID == lifecycle {
918+ return session, true
919+ }
920+ }
921+ return catalogue.RemoteCatalogSession{}, false
922+}
923+
924+// pickerSessionReplaced reports whether the exact lifecycle vanished but a
925+// same-name session remains, which is a replacement rather than a removal.
926+func pickerSessionReplaced(sessions []catalogue.RemoteCatalogSession, ref pickerSelectionRef) bool {
927+ if ref.name == "" {
928+ return false
929+ }
930+ for _, session := range sessions {
931+ if session.LifecycleID != ref.lifecycle && session.Name == ref.name {
932+ return true
933+ }
934+ }
935+ return false
936+}
A internal/usecase/client/picker_catalogue_test.go +941 -0
@@ -0,0 +1,941 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+ "github.com/bnema/vev/internal/protocol/catalogue"
15+)
16+
17+// Client-owned picker catalogue tests (Plan 001 P5.2b). Every case is
18+// deterministic: the fake clock, not wall time, decides freshness, and no case
19+// sleeps.
20+
21+const pickerTestFreshness = 30 * time.Second
22+
23+func pickerTestPolicy() ports.BrokerPolicy {
24+ return ports.BrokerPolicy{
25+ ProtocolVersion: protocol.Version,
26+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
27+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
28+ Transport: "quic",
29+ Trust: "trust",
30+ Launch: "launch",
31+ Isolation: "user",
32+ }
33+}
34+
35+func pickerTestIncarnation(seed byte) [16]byte {
36+ var id [16]byte
37+ id[0] = seed
38+ return id
39+}
40+
41+func pickerTestRegistration(endpoint string, seed byte) domain.RemoteRegistration {
42+ return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: pickerTestIncarnation(seed), Generation: 1}
43+}
44+
45+func pickerTestLifecycle(seed byte) domain.SessionLifecycleID { return pickerTestIncarnation(seed) }
46+
47+func pickerTestSession(name string, seed byte, state catalogue.RemoteCatalogSessionState) catalogue.RemoteCatalogSession {
48+ return catalogue.RemoteCatalogSession{LifecycleID: pickerTestLifecycle(seed), Name: name, State: state}
49+}
50+
51+func pickerTestSessionWithLifecycle(name string, lifecycle domain.SessionLifecycleID) catalogue.RemoteCatalogSession {
52+ return catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: name, State: catalogue_Up}
53+}
54+
55+func pickerTestLocalObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
56+ return ports.BrokerDaemonObservation{
57+ Local: true,
58+ DisplayOrigin: "local",
59+ Policy: pickerTestPolicy(),
60+ Identity: "local-daemon",
61+ Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(9)),
62+ ProtocolVersion: protocol.Version,
63+ Availability: domain.RemoteAvailabilityReachable,
64+ LastSuccess: now,
65+ InventoryKnown: true,
66+ Sessions: sessions,
67+ }
68+}
69+
70+func pickerTestRemoteObservation(endpoint string, registrationSeed, incarnationSeed byte, now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
71+ return ports.BrokerDaemonObservation{
72+ Endpoint: endpoint,
73+ DisplayOrigin: endpoint,
74+ Registration: pickerTestRegistration(endpoint, registrationSeed),
75+ Policy: pickerTestPolicy(),
76+ Identity: ports.BrokerDaemonIdentity("identity-" + endpoint),
77+ Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(incarnationSeed)),
78+ ProtocolVersion: protocol.Version,
79+ Availability: domain.RemoteAvailabilityReachable,
80+ LastSuccess: now,
81+ InventoryKnown: true,
82+ Sessions: sessions,
83+ }
84+}
85+
86+func pickerTestUnobservedObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
87+ return ports.BrokerDaemonObservation{
88+ Local: true,
89+ DisplayOrigin: "local",
90+ Policy: pickerTestPolicy(),
91+ Availability: domain.RemoteAvailabilityUnknown,
92+ InventoryKnown: len(sessions) > 0,
93+ Sessions: sessions,
94+ }
95+}
96+
97+func advancePickerClock(clock *supervisorTestClock, delta time.Duration) {
98+ clock.mu.Lock()
99+ clock.now = clock.now.Add(delta)
100+ clock.mu.Unlock()
101+}
102+
103+func pickerTestCatalogue(t *testing.T) (*pickerCatalogue, *supervisorTestClock) {
104+ t.Helper()
105+ clock := newSupervisorTestClock()
106+ return newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: pickerTestFreshness}), clock
107+}
108+
109+func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) {
110+ for _, line := range lines {
111+ if line.Kind != protocol.PickerLineSection && line.Label == label {
112+ return line, true
113+ }
114+ }
115+ return protocol.PickerLine{}, false
116+}
117+
118+func pickerSectionLabels(lines []protocol.PickerLine) []string {
119+ sections := make([]string, 0)
120+ for _, line := range lines {
121+ if line.Kind == protocol.PickerLineSection {
122+ sections = append(sections, line.Label)
123+ }
124+ }
125+ return sections
126+}
127+
128+func pickerSessionLabels(lines []protocol.PickerLine) []string {
129+ labels := make([]string, 0)
130+ for _, line := range lines {
131+ if line.Kind == protocol.PickerLineSession {
132+ labels = append(labels, line.Label)
133+ }
134+ }
135+ return labels
136+}
137+
138+func pickerTestBase() pickerResolveBase {
139+ return pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: ports.BrokerStreamID(1)}
140+}
141+
142+func TestPickerCatalogueEmptySnapshot(t *testing.T) {
143+ catalogue, _ := pickerTestCatalogue(t)
144+ require.False(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 0, Revision: 1}), "an invalid snapshot is never applied")
145+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 5, Revision: 1}))
146+ require.Empty(t, catalogue.Lines())
147+ require.Equal(t, ports.BrokerEpoch(5), catalogue.Epoch())
148+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision())
149+}
150+
151+func TestPickerCatalogueProjectionCases(t *testing.T) {
152+ clock := newSupervisorTestClock()
153+ now := clock.Now()
154+
155+ tests := []struct {
156+ name string
157+ daemons []ports.BrokerDaemonObservation
158+ wantSections []string
159+ wantSessions []string
160+ wantSessionKeys map[string]bool // label -> action available
161+ wantStatus map[string]protocol.PickerLineStatus
162+ wantDim map[string]bool
163+ }{
164+ {
165+ name: "local only",
166+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},
167+ wantSections: []string{"local"},
168+ wantSessions: []string{"alpha", "beta"},
169+ wantSessionKeys: map[string]bool{"alpha": true, "beta": true},
170+ },
171+ {
172+ name: "unobserved local daemon still projects",
173+ daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)},
174+ wantSections: []string{"local"},
175+ wantSessions: []string{},
176+ wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale},
177+ },
178+ {
179+ name: "no local daemon entry",
180+ daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},
181+ wantSections: []string{"user@arch"},
182+ wantSessions: []string{"remote-a"},
183+ wantSessionKeys: map[string]bool{"remote-a": true},
184+ },
185+ {
186+ name: "stale observation",
187+ daemons: []ports.BrokerDaemonObservation{
188+ func() ports.BrokerDaemonObservation {
189+ observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
190+ observation.LastSuccess = now.Add(-2 * pickerTestFreshness)
191+ return observation
192+ }(),
193+ },
194+ wantSections: []string{"local"},
195+ wantSessions: []string{"alpha"},
196+ wantSessionKeys: map[string]bool{"alpha": false},
197+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale},
198+ wantDim: map[string]bool{"alpha": true},
199+ },
200+ {
201+ name: "unavailable host",
202+ daemons: []ports.BrokerDaemonObservation{
203+ func() ports.BrokerDaemonObservation {
204+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
205+ observation.Availability = domain.RemoteAvailabilityUnreachable
206+ return observation
207+ }(),
208+ },
209+ wantSections: []string{"user@arch"},
210+ wantSessions: []string{"remote-a"},
211+ wantSessionKeys: map[string]bool{"remote-a": false},
212+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown},
213+ wantDim: map[string]bool{"remote-a": true},
214+ },
215+ {
216+ name: "incompatible host",
217+ daemons: []ports.BrokerDaemonObservation{
218+ func() ports.BrokerDaemonObservation {
219+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
220+ observation.ProtocolVersion = protocol.Version + 1
221+ return observation
222+ }(),
223+ },
224+ wantSections: []string{"user@arch"},
225+ wantSessions: []string{"remote-a"},
226+ wantSessionKeys: map[string]bool{"remote-a": false},
227+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion},
228+ wantDim: map[string]bool{"remote-a": true},
229+ },
230+ {
231+ name: "broken session",
232+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},
233+ wantSections: []string{"local"},
234+ wantSessions: []string{"alpha"},
235+ wantSessionKeys: map[string]bool{"alpha": false},
236+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},
237+ wantDim: map[string]bool{"alpha": true},
238+ },
239+ }
240+
241+ for _, tt := range tests {
242+ t.Run(tt.name, func(t *testing.T) {
243+ catalogue, _ := pickerTestCatalogue(t)
244+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: tt.daemons}))
245+ lines := catalogue.Lines()
246+ require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
247+ require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
248+ for label, actionable := range tt.wantSessionKeys {
249+ line, ok := pickerLineByLabel(lines, label)
250+ require.True(t, ok, "session %q must be projected", label)
251+ require.True(t, line.Focusable, "a projected row is always a cursor destination")
252+ if actionable {
253+ require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation")
254+ } else {
255+ require.Zero(t, line.Actions, "a refused row is inspectable but never committable")
256+ }
257+ }
258+ for label, status := range tt.wantStatus {
259+ line, ok := pickerLineByLabel(lines, label)
260+ require.True(t, ok, "row %q must be projected", label)
261+ require.Equal(t, status, line.Status, "row %q badge", label)
262+ }
263+ for label, dim := range tt.wantDim {
264+ line, ok := pickerLineByLabel(lines, label)
265+ require.True(t, ok)
266+ require.Equal(t, dim, line.Dim, "row %q dim", label)
267+ }
268+ })
269+ }
270+}
271+
272+func TestPickerCatalogueMonotonicUpdates(t *testing.T) {
273+ clock := newSupervisorTestClock()
274+ now := clock.Now()
275+ catalogue, _ := pickerTestCatalogue(t)
276+
277+ newer := ports.BrokerSnapshot{Epoch: 3, Revision: 4, Daemons: []ports.BrokerDaemonObservation{
278+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
279+ }}
280+ require.True(t, catalogue.Apply(newer))
281+ before := catalogue.Lines()
282+
283+ // A lower revision in the same epoch never supersedes and never regresses.
284+ older := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
285+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
286+ }}
287+ require.False(t, catalogue.Apply(older))
288+ require.Equal(t, before, catalogue.Lines())
289+ require.Equal(t, ports.BrokerRevision(4), catalogue.Revision())
290+
291+ // A new epoch is a full resynchronization and replaces every row.
292+ epoch := ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
293+ pickerTestLocalObservation(now, pickerTestSession("gamma", 3, catalogue_Up)),
294+ }}
295+ require.True(t, catalogue.Apply(epoch))
296+ require.Equal(t, []string{"gamma"}, pickerSessionLabels(catalogue.Lines()))
297+}
298+
299+// catalogue_Up/Broken keep the tables above short without importing the
300+// catalogue state into every literal.
301+const (
302+ catalogue_Up = catalogue.RemoteCatalogSessionState("up")
303+ catalogue_Broken = catalogue.RemoteCatalogSessionState("broken")
304+)
305+
306+func TestPickerCatalogueSlowObservationKeepsNewerState(t *testing.T) {
307+ clock := newSupervisorTestClock()
308+ now := clock.Now()
309+ catalogue, _ := pickerTestCatalogue(t)
310+
311+ fresh := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
312+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{fresh}}))
313+
314+ regressed := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-time.Minute), pickerTestSession("beta", 2, catalogue_Up))
315+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 3, Daemons: []ports.BrokerDaemonObservation{regressed}}), "the publication supersedes")
316+
317+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(catalogue.Lines()), "an older observed inventory never regresses a row")
318+}
319+
320+func TestPickerCatalogueIdentityReplacement(t *testing.T) {
321+ clock := newSupervisorTestClock()
322+ now := clock.Now()
323+ catalogue, _ := pickerTestCatalogue(t)
324+
325+ original := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
326+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)),
327+ }}
328+ require.True(t, catalogue.Apply(original))
329+ oldLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
330+ require.True(t, ok)
331+ oldKey := oldLine.Key
332+
333+ replaced := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
334+ pickerTestRemoteObservation("user@arch", 2, 2, now, pickerTestSession("alpha", 9, catalogue_Up)),
335+ }}
336+ require.True(t, catalogue.Apply(replaced))
337+ newLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
338+ require.True(t, ok)
339+ require.NotEqual(t, oldKey, newLine.Key, "a replaced identity gets a new opaque key")
340+
341+ // The old key is still recognized and refused as a replacement, not silently
342+ // re-bound to the new session.
343+ _, err := catalogue.Resolve(oldKey, pickerTestBase())
344+ require.Error(t, err)
345+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueReplaced), "same endpoint with a new incarnation is a replacement")
346+}
347+
348+func TestPickerCatalogueStableRowOrder(t *testing.T) {
349+ clock := newSupervisorTestClock()
350+ now := clock.Now()
351+ catalogue, _ := pickerTestCatalogue(t)
352+
353+ first := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
354+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
355+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
356+ pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up)),
357+ }}
358+ require.True(t, catalogue.Apply(first))
359+ require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines()))
360+
361+ // The publication reorders the remote hosts and changes one unrelated
362+ // session; the first-seen order never reshuffles. The local daemon stays
363+ // first, as every valid broker publication requires.
364+ second := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
365+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
366+ pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up), pickerTestSession("b-two", 4, catalogue_Up)),
367+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
368+ }}
369+ require.True(t, catalogue.Apply(second))
370+ require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines()))
371+ require.Equal(t, []string{"alpha", "a-one", "b-one", "b-two"}, pickerSessionLabels(catalogue.Lines()))
372+}
373+
374+func TestPickerCatalogueDeterministicKeys(t *testing.T) {
375+ clock := newSupervisorTestClock()
376+ now := clock.Now()
377+ daemons := []ports.BrokerDaemonObservation{
378+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
379+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
380+ }
381+ first, _ := pickerTestCatalogue(t)
382+ second, _ := pickerTestCatalogue(t)
383+ require.True(t, first.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons}))
384+ require.True(t, second.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons}))
385+ require.Equal(t, first.Lines(), second.Lines())
386+}
387+
388+func TestPickerCatalogueSelectionResolution(t *testing.T) {
389+ clock := newSupervisorTestClock()
390+ now := clock.Now()
391+ base := pickerTestBase()
392+
393+ tests := []struct {
394+ name string
395+ build func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string
396+ wantErr pickerCatalogueErrorCode
397+ wantLocal bool
398+ wantAdmission ports.BrokerStreamAdmission
399+ }{
400+ {
401+ name: "local exact attaches to the lifecycle",
402+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
403+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
404+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
405+ }}))
406+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
407+ require.True(t, ok)
408+ return line.Key
409+ },
410+ wantLocal: true,
411+ wantAdmission: ports.BrokerAdmissionExact,
412+ },
413+ {
414+ name: "remote exact carries the configured authority",
415+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
416+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
417+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
418+ }}))
419+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
420+ require.True(t, ok)
421+ return line.Key
422+ },
423+ wantAdmission: ports.BrokerAdmissionExact,
424+ },
425+ {
426+ name: "gone host",
427+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
428+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
429+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
430+ }}))
431+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
432+ require.True(t, ok)
433+ key := line.Key
434+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2}))
435+ return key
436+ },
437+ wantErr: pickerCatalogueGone,
438+ },
439+ {
440+ name: "stale observation",
441+ build: func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string {
442+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
443+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
444+ }}))
445+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
446+ require.True(t, ok)
447+ advancePickerClock(clock, 2*pickerTestFreshness)
448+ return line.Key
449+ },
450+ wantErr: pickerCatalogueStale,
451+ },
452+ {
453+ name: "incompatible host",
454+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
455+ incompatible := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
456+ incompatible.ProtocolVersion = protocol.Version + 1
457+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{incompatible}}))
458+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
459+ require.True(t, ok)
460+ return line.Key
461+ },
462+ wantErr: pickerCatalogueIncompatible,
463+ },
464+ {
465+ name: "unavailable host",
466+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
467+ unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
468+ unavailable.Availability = domain.RemoteAvailabilityUnreachable
469+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unavailable}}))
470+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
471+ require.True(t, ok)
472+ return line.Key
473+ },
474+ wantErr: pickerCatalogueUnavailable,
475+ },
476+ {
477+ name: "epoch change",
478+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
479+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
480+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
481+ }}))
482+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
483+ require.True(t, ok)
484+ key := line.Key
485+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
486+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
487+ }}))
488+ return key
489+ },
490+ wantErr: pickerCatalogueUnknown,
491+ },
492+ {
493+ name: "session lifecycle replaced",
494+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
495+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
496+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
497+ }}))
498+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
499+ require.True(t, ok)
500+ key := line.Key
501+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
502+ pickerTestLocalObservation(now, pickerTestSession("alpha", 5, catalogue_Up)),
503+ }}))
504+ return key
505+ },
506+ wantErr: pickerCatalogueReplaced,
507+ },
508+ {
509+ name: "session gone",
510+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
511+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
512+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up)),
513+ }}))
514+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
515+ require.True(t, ok)
516+ key := line.Key
517+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
518+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
519+ }}))
520+ return key
521+ },
522+ wantErr: pickerCatalogueGone,
523+ },
524+ {
525+ name: "broken session",
526+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
527+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
528+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)),
529+ }}))
530+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
531+ require.True(t, ok)
532+ return line.Key
533+ },
534+ wantErr: pickerCatalogueUnavailable,
535+ },
536+ }
537+
538+ for _, tt := range tests {
539+ t.Run(tt.name, func(t *testing.T) {
540+ catalogue, clock := pickerTestCatalogue(t)
541+ key := tt.build(t, catalogue, clock)
542+ request, err := catalogue.Resolve(key, base)
543+ if tt.wantErr != 0 {
544+ require.Error(t, err)
545+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
546+ return
547+ }
548+ require.NoError(t, err)
549+ require.NoError(t, request.Validate(), "a resolved request always validates")
550+ require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
551+ require.Equal(t, tt.wantAdmission, request.Admission)
552+ require.Equal(t, tt.wantLocal, request.Local)
553+ require.Equal(t, ports.BrokerEpoch(3), request.Epoch)
554+ require.Equal(t, base.Connection, request.Connection)
555+ require.Equal(t, base.Stream, request.Stream)
556+ })
557+ }
558+}
559+
560+func TestPickerCatalogueRemoteSelectionCarriesExactIdentity(t *testing.T) {
561+ clock := newSupervisorTestClock()
562+ now := clock.Now()
563+ catalogue, _ := pickerTestCatalogue(t)
564+ registration := pickerTestRegistration("user@arch", 1)
565+ lifecycle := pickerTestLifecycle(2)
566+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
567+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
568+ }}))
569+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
570+ require.True(t, ok)
571+
572+ request, err := catalogue.Resolve(line.Key, pickerTestBase())
573+ require.NoError(t, err)
574+ require.Equal(t, "user@arch", request.Endpoint)
575+ require.Equal(t, registration, request.Registration)
576+ require.Equal(t, protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "remote-a"}, request.Target)
577+ require.Equal(t, pickerTestPolicy(), request.Policy)
578+}
579+
580+func TestPickerCatalogueCreationResolution(t *testing.T) {
581+ clock := newSupervisorTestClock()
582+ now := clock.Now()
583+ base := pickerTestBase()
584+
585+ tests := []struct {
586+ name string
587+ local bool
588+ endpoint string
589+ kind pickerSelectionKind
590+ sessionName string
591+ snapshot func() ports.BrokerSnapshot
592+ wantErr pickerCatalogueErrorCode
593+ wantAdmission ports.BrokerStreamAdmission
594+ }{
595+ {
596+ name: "named creation on the local daemon",
597+ local: true,
598+ kind: pickerSelectionCreateNamed,
599+ sessionName: "fresh",
600+ snapshot: func() ports.BrokerSnapshot {
601+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}}
602+ },
603+ wantAdmission: ports.BrokerAdmissionCreateNamed,
604+ },
605+ {
606+ name: "ephemeral creation on a remote daemon",
607+ endpoint: "user@arch",
608+ kind: pickerSelectionCreateEphemeral,
609+ snapshot: func() ports.BrokerSnapshot {
610+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now)}}
611+ },
612+ wantAdmission: ports.BrokerAdmissionCreateEphemeral,
613+ },
614+ {
615+ name: "named creation without a name",
616+ local: true,
617+ kind: pickerSelectionCreateNamed,
618+ snapshot: func() ports.BrokerSnapshot {
619+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}}
620+ },
621+ wantErr: pickerCatalogueInvalidName,
622+ },
623+ {
624+ name: "creation on a missing remote daemon",
625+ endpoint: "user@arch",
626+ kind: pickerSelectionCreateEphemeral,
627+ snapshot: func() ports.BrokerSnapshot { return ports.BrokerSnapshot{Epoch: 3, Revision: 1} },
628+ wantErr: pickerCatalogueGone,
629+ },
630+ {
631+ name: "creation on an incompatible daemon",
632+ local: true,
633+ kind: pickerSelectionCreateEphemeral,
634+ snapshot: func() ports.BrokerSnapshot {
635+ observation := pickerTestLocalObservation(now)
636+ observation.ProtocolVersion = protocol.Version + 1
637+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}}
638+ },
639+ wantErr: pickerCatalogueIncompatible,
640+ },
641+ }
642+
643+ for _, tt := range tests {
644+ t.Run(tt.name, func(t *testing.T) {
645+ catalogue, _ := pickerTestCatalogue(t)
646+ require.True(t, catalogue.Apply(tt.snapshot()))
647+ request, err := catalogue.ResolveCreation(tt.local, tt.endpoint, tt.kind, tt.sessionName, base)
648+ if tt.wantErr != 0 {
649+ require.Error(t, err)
650+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
651+ return
652+ }
653+ require.NoError(t, err)
654+ require.NoError(t, request.Validate())
655+ require.Equal(t, tt.wantAdmission, request.Admission)
656+ })
657+ }
658+}
659+
660+func TestPickerCatalogueUnknownKeyRefused(t *testing.T) {
661+ catalogue, _ := pickerTestCatalogue(t)
662+ _, err := catalogue.Resolve("missing", pickerTestBase())
663+ require.Error(t, err)
664+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown))
665+}
666+
667+// TestPickerCatalogueEpochFenceOnCapturedRef pins the selection revision: a
668+// driver that captured a row's exact identity at one broker epoch may not
669+// resolve it after the broker process was replaced, even when the endpoint and
670+// session lifecycle happen to look the same.
671+func TestPickerCatalogueEpochFenceOnCapturedRef(t *testing.T) {
672+ clock := newSupervisorTestClock()
673+ now := clock.Now()
674+ catalogue, _ := pickerTestCatalogue(t)
675+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
676+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
677+ }}))
678+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
679+ require.True(t, ok)
680+ ref, ok := catalogue.Ref(line.Key)
681+ require.True(t, ok)
682+
683+ // A new broker epoch resynchronizes the catalogue; the captured identity is
684+ // fenced even though its key is recomputed identically.
685+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
686+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
687+ }}))
688+ _, err := catalogue.ResolveRef(ref, pickerTestBase())
689+ require.Error(t, err)
690+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueEpochStale))
691+}
692+
693+func TestPickerCatalogueRetiredRefFenceIsBounded(t *testing.T) {
694+ catalogue, _ := pickerTestCatalogue(t)
695+ base := pickerTestBase()
696+ const inserted = pickerCatalogueMaxRetiredRefs + 8
697+ keys := make([]string, 0, inserted)
698+ for i := range inserted {
699+ name := fmt.Sprintf("s-%d", i)
700+ session := pickerTestSession(name, byte(i%200+1), catalogue_Up)
701+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(i + 1), Daemons: []ports.BrokerDaemonObservation{
702+ pickerTestLocalObservation(time.Unix(1000, 0), session),
703+ }}))
704+ line, ok := pickerLineByLabel(catalogue.Lines(), name)
705+ require.True(t, ok)
706+ keys = append(keys, line.Key)
707+ }
708+ // The fence holds the exact bound: only the newest retired keys survive.
709+ require.Len(t, catalogue.retired, pickerCatalogueMaxRetiredRefs)
710+ require.Len(t, catalogue.retiredOrder, pickerCatalogueMaxRetiredRefs)
711+
712+ // A retained retired key still classifies as gone: its lifecycle left the
713+ // projection and no same-name replacement exists.
714+ retainedKey := keys[len(keys)-2]
715+ _, err := catalogue.Resolve(retainedKey, base)
716+ require.Error(t, err)
717+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone), "a retained retired key must classify as gone: %v", err)
718+
719+ // The oldest key was evicted from the bounded fence and is now unknown.
720+ evictedKey := keys[0]
721+ _, err = catalogue.Resolve(evictedKey, base)
722+ require.Error(t, err)
723+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown), "an evicted retired key must classify as unknown: %v", err)
724+}
725+
726+// TestPickerCatalogueRetiredRefFenceConcurrentApply exercises the retired-ref
727+// fence under -race: many goroutines fold distinct publications concurrently
728+// and the fence may never exceed its bound.
729+func TestPickerCatalogueRetiredRefFenceConcurrentApply(t *testing.T) {
730+ catalogue, _ := pickerTestCatalogue(t)
731+ const (
732+ workers = 8
733+ perWorker = 64
734+ )
735+ var wait sync.WaitGroup
736+ for worker := range workers {
737+ wait.Add(1)
738+ go func(worker int) {
739+ defer wait.Done()
740+ for i := range perWorker {
741+ var lifecycle domain.SessionLifecycleID
742+ lifecycle[0] = byte(i + 1)
743+ lifecycle[1] = byte(worker + 1)
744+ session := pickerTestSessionWithLifecycle(fmt.Sprintf("w%d-%d", worker, i), lifecycle)
745+ catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(worker*perWorker + i + 1), Daemons: []ports.BrokerDaemonObservation{
746+ pickerTestLocalObservation(time.Unix(1000, 0), session),
747+ }})
748+ }
749+ }(worker)
750+ }
751+ wait.Wait()
752+ require.LessOrEqual(t, len(catalogue.retired), pickerCatalogueMaxRetiredRefs)
753+ require.LessOrEqual(t, len(catalogue.retiredOrder), pickerCatalogueMaxRetiredRefs)
754+}
755+
756+// TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility pins the
757+// authoritative ordering: availability first, then the reachable version
758+// branch. An unobserved (ProtocolVersion 0) or unreachable daemon is never a
759+// version mismatch.
760+func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) {
761+ now := time.Unix(1000, 0)
762+ reachable := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation {
763+ observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
764+ if mutate != nil {
765+ mutate(&observation)
766+ }
767+ return observation
768+ }
769+ unobserved := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation {
770+ observation := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
771+ if mutate != nil {
772+ mutate(&observation)
773+ }
774+ return observation
775+ }
776+
777+ tests := []struct {
778+ name string
779+ observation ports.BrokerDaemonObservation
780+ fresh bool
781+ wantStatus protocol.PickerLineStatus
782+ wantReason string
783+ }{
784+ {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
785+ {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
786+ {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure},
787+ {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed},
788+ {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) {
789+ o.Availability = domain.RemoteAvailabilityUnreachable
790+ o.ProtocolVersion = protocol.Version + 1
791+ }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
792+ {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
793+ {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""},
794+ {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale},
795+ {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
796+ {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
797+ {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing},
798+ }
799+ for _, tt := range tests {
800+ t.Run(tt.name, func(t *testing.T) {
801+ require.Equal(t, tt.wantStatus, pickerObservationStatus(tt.observation, tt.fresh))
802+ require.Equal(t, tt.wantReason, pickerObservationReason(tt.observation, tt.fresh))
803+ })
804+ }
805+}
806+
807+// TestPickerCatalogueAvailabilityFirstResolution proves resolution checks
808+// availability before compatibility, so an unobserved or unreachable host
809+// refuses as unavailable rather than as a version mismatch.
810+func TestPickerCatalogueAvailabilityFirstResolution(t *testing.T) {
811+ now := time.Unix(1000, 0)
812+ tests := []struct {
813+ name string
814+ daemon ports.BrokerDaemonObservation
815+ wantErr pickerCatalogueErrorCode
816+ }{
817+ {
818+ name: "unobserved unknown refuses unavailable",
819+ daemon: pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
820+ wantErr: pickerCatalogueUnavailable,
821+ },
822+ {
823+ name: "unreachable observed mismatch refuses unavailable",
824+ daemon: func() ports.BrokerDaemonObservation {
825+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
826+ o.Availability = domain.RemoteAvailabilityUnreachable
827+ o.ProtocolVersion = protocol.Version + 1
828+ return o
829+ }(),
830+ wantErr: pickerCatalogueUnavailable,
831+ },
832+ {
833+ name: "auth failed refuses unavailable",
834+ daemon: func() ports.BrokerDaemonObservation {
835+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
836+ o.Availability = domain.RemoteAvailabilityAuthFailed
837+ return o
838+ }(),
839+ wantErr: pickerCatalogueUnavailable,
840+ },
841+ {
842+ name: "invalid response refuses unavailable",
843+ daemon: func() ports.BrokerDaemonObservation {
844+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
845+ o.Availability = domain.RemoteAvailabilityInvalidResponse
846+ return o
847+ }(),
848+ wantErr: pickerCatalogueUnavailable,
849+ },
850+ {
851+ name: "reachable unobserved refuses unavailable",
852+ daemon: func() ports.BrokerDaemonObservation {
853+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
854+ o.Availability = domain.RemoteAvailabilityReachable
855+ return o
856+ }(),
857+ wantErr: pickerCatalogueUnavailable,
858+ },
859+ {
860+ name: "reachable mismatch refuses incompatible",
861+ daemon: func() ports.BrokerDaemonObservation {
862+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
863+ o.ProtocolVersion = protocol.Version + 1
864+ return o
865+ }(),
866+ wantErr: pickerCatalogueIncompatible,
867+ },
868+ {
869+ name: "legacy incompatible availability refuses incompatible",
870+ daemon: func() ports.BrokerDaemonObservation {
871+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
872+ o.Availability = domain.RemoteAvailabilityIncompatible
873+ return o
874+ }(),
875+ wantErr: pickerCatalogueIncompatible,
876+ },
877+ }
878+ for _, tt := range tests {
879+ t.Run(tt.name, func(t *testing.T) {
880+ catalogue, _ := pickerTestCatalogue(t)
881+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
882+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
883+ require.True(t, ok)
884+ _, err := catalogue.Resolve(line.Key, pickerTestBase())
885+ require.Error(t, err)
886+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
887+ })
888+ }
889+}
890+
891+// TestPickerCatalogueRejectsInvalidSnapshot pins the projection trust
892+// boundary: a snapshot that fails its own contract is refused and the previous
893+// projection is kept intact.
894+func TestPickerCatalogueRejectsInvalidSnapshot(t *testing.T) {
895+ catalogue, clock := pickerTestCatalogue(t)
896+ valid := pickerTestSnapshot(3, 1, clock.Now(), "alpha")
897+ require.True(t, catalogue.Apply(valid))
898+ validLines := catalogue.Lines()
899+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision())
900+
901+ invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta")
902+ invalid.Daemons[0].Availability = 0 // out of range: availability is never zero
903+ require.False(t, catalogue.Apply(invalid), "an invalid snapshot is refused")
904+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision(), "the previous projection is kept")
905+ require.Equal(t, validLines, catalogue.Lines())
906+}
907+
908+// TestPickerCatalogueUnobservedDaemonProjects proves an unobserved daemon
909+// (Availability Unknown, ProtocolVersion 0, DisplayOrigin set) still projects a
910+// section and host row, classified as stale/refreshing rather than as a version
911+// mismatch.
912+func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
913+ catalogue, _ := pickerTestCatalogue(t)
914+ unobserved := pickerTestUnobservedObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up))
915+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}))
916+
917+ lines := catalogue.Lines()
918+ require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
919+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
920+ host, ok := pickerLineByLabel(lines, "local")
921+ require.True(t, ok)
922+ require.Equal(t, protocol.PickerLineStatusStale, host.Status)
923+ require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
924+ session, ok := pickerLineByLabel(lines, "alpha")
925+ require.True(t, ok)
926+ require.True(t, session.Focusable)
927+ require.Zero(t, session.Actions, "an unobserved host row is inspectable only")
928+ require.True(t, session.Dim)
929+}
930+
931+// TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels
932+// never fall back to a raw endpoint.
933+func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) {
934+ raw := pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))
935+ raw.DisplayOrigin = ""
936+ require.Equal(t, pickerCatalogueOriginFallback, pickerOriginLabel(raw))
937+ require.NotContains(t, pickerOriginLabel(raw), "user@arch")
938+
939+ require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0))))
940+ require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))))
941+}
@@ -0,0 +1,941 @@
1+package client
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol"
14+ "github.com/bnema/vev/internal/protocol/catalogue"
15+)
16+
17+// Client-owned picker catalogue tests (Plan 001 P5.2b). Every case is
18+// deterministic: the fake clock, not wall time, decides freshness, and no case
19+// sleeps.
20+
21+const pickerTestFreshness = 30 * time.Second
22+
23+func pickerTestPolicy() ports.BrokerPolicy {
24+ return ports.BrokerPolicy{
25+ ProtocolVersion: protocol.Version,
26+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
27+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
28+ Transport: "quic",
29+ Trust: "trust",
30+ Launch: "launch",
31+ Isolation: "user",
32+ }
33+}
34+
35+func pickerTestIncarnation(seed byte) [16]byte {
36+ var id [16]byte
37+ id[0] = seed
38+ return id
39+}
40+
41+func pickerTestRegistration(endpoint string, seed byte) domain.RemoteRegistration {
42+ return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: pickerTestIncarnation(seed), Generation: 1}
43+}
44+
45+func pickerTestLifecycle(seed byte) domain.SessionLifecycleID { return pickerTestIncarnation(seed) }
46+
47+func pickerTestSession(name string, seed byte, state catalogue.RemoteCatalogSessionState) catalogue.RemoteCatalogSession {
48+ return catalogue.RemoteCatalogSession{LifecycleID: pickerTestLifecycle(seed), Name: name, State: state}
49+}
50+
51+func pickerTestSessionWithLifecycle(name string, lifecycle domain.SessionLifecycleID) catalogue.RemoteCatalogSession {
52+ return catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: name, State: catalogue_Up}
53+}
54+
55+func pickerTestLocalObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
56+ return ports.BrokerDaemonObservation{
57+ Local: true,
58+ DisplayOrigin: "local",
59+ Policy: pickerTestPolicy(),
60+ Identity: "local-daemon",
61+ Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(9)),
62+ ProtocolVersion: protocol.Version,
63+ Availability: domain.RemoteAvailabilityReachable,
64+ LastSuccess: now,
65+ InventoryKnown: true,
66+ Sessions: sessions,
67+ }
68+}
69+
70+func pickerTestRemoteObservation(endpoint string, registrationSeed, incarnationSeed byte, now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
71+ return ports.BrokerDaemonObservation{
72+ Endpoint: endpoint,
73+ DisplayOrigin: endpoint,
74+ Registration: pickerTestRegistration(endpoint, registrationSeed),
75+ Policy: pickerTestPolicy(),
76+ Identity: ports.BrokerDaemonIdentity("identity-" + endpoint),
77+ Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(incarnationSeed)),
78+ ProtocolVersion: protocol.Version,
79+ Availability: domain.RemoteAvailabilityReachable,
80+ LastSuccess: now,
81+ InventoryKnown: true,
82+ Sessions: sessions,
83+ }
84+}
85+
86+func pickerTestUnobservedObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation {
87+ return ports.BrokerDaemonObservation{
88+ Local: true,
89+ DisplayOrigin: "local",
90+ Policy: pickerTestPolicy(),
91+ Availability: domain.RemoteAvailabilityUnknown,
92+ InventoryKnown: len(sessions) > 0,
93+ Sessions: sessions,
94+ }
95+}
96+
97+func advancePickerClock(clock *supervisorTestClock, delta time.Duration) {
98+ clock.mu.Lock()
99+ clock.now = clock.now.Add(delta)
100+ clock.mu.Unlock()
101+}
102+
103+func pickerTestCatalogue(t *testing.T) (*pickerCatalogue, *supervisorTestClock) {
104+ t.Helper()
105+ clock := newSupervisorTestClock()
106+ return newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: pickerTestFreshness}), clock
107+}
108+
109+func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) {
110+ for _, line := range lines {
111+ if line.Kind != protocol.PickerLineSection && line.Label == label {
112+ return line, true
113+ }
114+ }
115+ return protocol.PickerLine{}, false
116+}
117+
118+func pickerSectionLabels(lines []protocol.PickerLine) []string {
119+ sections := make([]string, 0)
120+ for _, line := range lines {
121+ if line.Kind == protocol.PickerLineSection {
122+ sections = append(sections, line.Label)
123+ }
124+ }
125+ return sections
126+}
127+
128+func pickerSessionLabels(lines []protocol.PickerLine) []string {
129+ labels := make([]string, 0)
130+ for _, line := range lines {
131+ if line.Kind == protocol.PickerLineSession {
132+ labels = append(labels, line.Label)
133+ }
134+ }
135+ return labels
136+}
137+
138+func pickerTestBase() pickerResolveBase {
139+ return pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: ports.BrokerStreamID(1)}
140+}
141+
142+func TestPickerCatalogueEmptySnapshot(t *testing.T) {
143+ catalogue, _ := pickerTestCatalogue(t)
144+ require.False(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 0, Revision: 1}), "an invalid snapshot is never applied")
145+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 5, Revision: 1}))
146+ require.Empty(t, catalogue.Lines())
147+ require.Equal(t, ports.BrokerEpoch(5), catalogue.Epoch())
148+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision())
149+}
150+
151+func TestPickerCatalogueProjectionCases(t *testing.T) {
152+ clock := newSupervisorTestClock()
153+ now := clock.Now()
154+
155+ tests := []struct {
156+ name string
157+ daemons []ports.BrokerDaemonObservation
158+ wantSections []string
159+ wantSessions []string
160+ wantSessionKeys map[string]bool // label -> action available
161+ wantStatus map[string]protocol.PickerLineStatus
162+ wantDim map[string]bool
163+ }{
164+ {
165+ name: "local only",
166+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))},
167+ wantSections: []string{"local"},
168+ wantSessions: []string{"alpha", "beta"},
169+ wantSessionKeys: map[string]bool{"alpha": true, "beta": true},
170+ },
171+ {
172+ name: "unobserved local daemon still projects",
173+ daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)},
174+ wantSections: []string{"local"},
175+ wantSessions: []string{},
176+ wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale},
177+ },
178+ {
179+ name: "no local daemon entry",
180+ daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))},
181+ wantSections: []string{"user@arch"},
182+ wantSessions: []string{"remote-a"},
183+ wantSessionKeys: map[string]bool{"remote-a": true},
184+ },
185+ {
186+ name: "stale observation",
187+ daemons: []ports.BrokerDaemonObservation{
188+ func() ports.BrokerDaemonObservation {
189+ observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
190+ observation.LastSuccess = now.Add(-2 * pickerTestFreshness)
191+ return observation
192+ }(),
193+ },
194+ wantSections: []string{"local"},
195+ wantSessions: []string{"alpha"},
196+ wantSessionKeys: map[string]bool{"alpha": false},
197+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale},
198+ wantDim: map[string]bool{"alpha": true},
199+ },
200+ {
201+ name: "unavailable host",
202+ daemons: []ports.BrokerDaemonObservation{
203+ func() ports.BrokerDaemonObservation {
204+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
205+ observation.Availability = domain.RemoteAvailabilityUnreachable
206+ return observation
207+ }(),
208+ },
209+ wantSections: []string{"user@arch"},
210+ wantSessions: []string{"remote-a"},
211+ wantSessionKeys: map[string]bool{"remote-a": false},
212+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown},
213+ wantDim: map[string]bool{"remote-a": true},
214+ },
215+ {
216+ name: "incompatible host",
217+ daemons: []ports.BrokerDaemonObservation{
218+ func() ports.BrokerDaemonObservation {
219+ observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))
220+ observation.ProtocolVersion = protocol.Version + 1
221+ return observation
222+ }(),
223+ },
224+ wantSections: []string{"user@arch"},
225+ wantSessions: []string{"remote-a"},
226+ wantSessionKeys: map[string]bool{"remote-a": false},
227+ wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion},
228+ wantDim: map[string]bool{"remote-a": true},
229+ },
230+ {
231+ name: "broken session",
232+ daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))},
233+ wantSections: []string{"local"},
234+ wantSessions: []string{"alpha"},
235+ wantSessionKeys: map[string]bool{"alpha": false},
236+ wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError},
237+ wantDim: map[string]bool{"alpha": true},
238+ },
239+ }
240+
241+ for _, tt := range tests {
242+ t.Run(tt.name, func(t *testing.T) {
243+ catalogue, _ := pickerTestCatalogue(t)
244+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: tt.daemons}))
245+ lines := catalogue.Lines()
246+ require.Equal(t, tt.wantSections, pickerSectionLabels(lines))
247+ require.Equal(t, tt.wantSessions, pickerSessionLabels(lines))
248+ for label, actionable := range tt.wantSessionKeys {
249+ line, ok := pickerLineByLabel(lines, label)
250+ require.True(t, ok, "session %q must be projected", label)
251+ require.True(t, line.Focusable, "a projected row is always a cursor destination")
252+ if actionable {
253+ require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation")
254+ } else {
255+ require.Zero(t, line.Actions, "a refused row is inspectable but never committable")
256+ }
257+ }
258+ for label, status := range tt.wantStatus {
259+ line, ok := pickerLineByLabel(lines, label)
260+ require.True(t, ok, "row %q must be projected", label)
261+ require.Equal(t, status, line.Status, "row %q badge", label)
262+ }
263+ for label, dim := range tt.wantDim {
264+ line, ok := pickerLineByLabel(lines, label)
265+ require.True(t, ok)
266+ require.Equal(t, dim, line.Dim, "row %q dim", label)
267+ }
268+ })
269+ }
270+}
271+
272+func TestPickerCatalogueMonotonicUpdates(t *testing.T) {
273+ clock := newSupervisorTestClock()
274+ now := clock.Now()
275+ catalogue, _ := pickerTestCatalogue(t)
276+
277+ newer := ports.BrokerSnapshot{Epoch: 3, Revision: 4, Daemons: []ports.BrokerDaemonObservation{
278+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
279+ }}
280+ require.True(t, catalogue.Apply(newer))
281+ before := catalogue.Lines()
282+
283+ // A lower revision in the same epoch never supersedes and never regresses.
284+ older := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
285+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
286+ }}
287+ require.False(t, catalogue.Apply(older))
288+ require.Equal(t, before, catalogue.Lines())
289+ require.Equal(t, ports.BrokerRevision(4), catalogue.Revision())
290+
291+ // A new epoch is a full resynchronization and replaces every row.
292+ epoch := ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
293+ pickerTestLocalObservation(now, pickerTestSession("gamma", 3, catalogue_Up)),
294+ }}
295+ require.True(t, catalogue.Apply(epoch))
296+ require.Equal(t, []string{"gamma"}, pickerSessionLabels(catalogue.Lines()))
297+}
298+
299+// catalogue_Up/Broken keep the tables above short without importing the
300+// catalogue state into every literal.
301+const (
302+ catalogue_Up = catalogue.RemoteCatalogSessionState("up")
303+ catalogue_Broken = catalogue.RemoteCatalogSessionState("broken")
304+)
305+
306+func TestPickerCatalogueSlowObservationKeepsNewerState(t *testing.T) {
307+ clock := newSupervisorTestClock()
308+ now := clock.Now()
309+ catalogue, _ := pickerTestCatalogue(t)
310+
311+ fresh := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
312+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{fresh}}))
313+
314+ regressed := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-time.Minute), pickerTestSession("beta", 2, catalogue_Up))
315+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 3, Daemons: []ports.BrokerDaemonObservation{regressed}}), "the publication supersedes")
316+
317+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(catalogue.Lines()), "an older observed inventory never regresses a row")
318+}
319+
320+func TestPickerCatalogueIdentityReplacement(t *testing.T) {
321+ clock := newSupervisorTestClock()
322+ now := clock.Now()
323+ catalogue, _ := pickerTestCatalogue(t)
324+
325+ original := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
326+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)),
327+ }}
328+ require.True(t, catalogue.Apply(original))
329+ oldLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
330+ require.True(t, ok)
331+ oldKey := oldLine.Key
332+
333+ replaced := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
334+ pickerTestRemoteObservation("user@arch", 2, 2, now, pickerTestSession("alpha", 9, catalogue_Up)),
335+ }}
336+ require.True(t, catalogue.Apply(replaced))
337+ newLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
338+ require.True(t, ok)
339+ require.NotEqual(t, oldKey, newLine.Key, "a replaced identity gets a new opaque key")
340+
341+ // The old key is still recognized and refused as a replacement, not silently
342+ // re-bound to the new session.
343+ _, err := catalogue.Resolve(oldKey, pickerTestBase())
344+ require.Error(t, err)
345+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueReplaced), "same endpoint with a new incarnation is a replacement")
346+}
347+
348+func TestPickerCatalogueStableRowOrder(t *testing.T) {
349+ clock := newSupervisorTestClock()
350+ now := clock.Now()
351+ catalogue, _ := pickerTestCatalogue(t)
352+
353+ first := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
354+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
355+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
356+ pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up)),
357+ }}
358+ require.True(t, catalogue.Apply(first))
359+ require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines()))
360+
361+ // The publication reorders the remote hosts and changes one unrelated
362+ // session; the first-seen order never reshuffles. The local daemon stays
363+ // first, as every valid broker publication requires.
364+ second := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
365+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
366+ pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up), pickerTestSession("b-two", 4, catalogue_Up)),
367+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
368+ }}
369+ require.True(t, catalogue.Apply(second))
370+ require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines()))
371+ require.Equal(t, []string{"alpha", "a-one", "b-one", "b-two"}, pickerSessionLabels(catalogue.Lines()))
372+}
373+
374+func TestPickerCatalogueDeterministicKeys(t *testing.T) {
375+ clock := newSupervisorTestClock()
376+ now := clock.Now()
377+ daemons := []ports.BrokerDaemonObservation{
378+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
379+ pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)),
380+ }
381+ first, _ := pickerTestCatalogue(t)
382+ second, _ := pickerTestCatalogue(t)
383+ require.True(t, first.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons}))
384+ require.True(t, second.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons}))
385+ require.Equal(t, first.Lines(), second.Lines())
386+}
387+
388+func TestPickerCatalogueSelectionResolution(t *testing.T) {
389+ clock := newSupervisorTestClock()
390+ now := clock.Now()
391+ base := pickerTestBase()
392+
393+ tests := []struct {
394+ name string
395+ build func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string
396+ wantErr pickerCatalogueErrorCode
397+ wantLocal bool
398+ wantAdmission ports.BrokerStreamAdmission
399+ }{
400+ {
401+ name: "local exact attaches to the lifecycle",
402+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
403+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
404+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
405+ }}))
406+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
407+ require.True(t, ok)
408+ return line.Key
409+ },
410+ wantLocal: true,
411+ wantAdmission: ports.BrokerAdmissionExact,
412+ },
413+ {
414+ name: "remote exact carries the configured authority",
415+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
416+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
417+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
418+ }}))
419+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
420+ require.True(t, ok)
421+ return line.Key
422+ },
423+ wantAdmission: ports.BrokerAdmissionExact,
424+ },
425+ {
426+ name: "gone host",
427+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
428+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
429+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
430+ }}))
431+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
432+ require.True(t, ok)
433+ key := line.Key
434+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2}))
435+ return key
436+ },
437+ wantErr: pickerCatalogueGone,
438+ },
439+ {
440+ name: "stale observation",
441+ build: func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string {
442+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
443+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
444+ }}))
445+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
446+ require.True(t, ok)
447+ advancePickerClock(clock, 2*pickerTestFreshness)
448+ return line.Key
449+ },
450+ wantErr: pickerCatalogueStale,
451+ },
452+ {
453+ name: "incompatible host",
454+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
455+ incompatible := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
456+ incompatible.ProtocolVersion = protocol.Version + 1
457+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{incompatible}}))
458+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
459+ require.True(t, ok)
460+ return line.Key
461+ },
462+ wantErr: pickerCatalogueIncompatible,
463+ },
464+ {
465+ name: "unavailable host",
466+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
467+ unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up))
468+ unavailable.Availability = domain.RemoteAvailabilityUnreachable
469+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unavailable}}))
470+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
471+ require.True(t, ok)
472+ return line.Key
473+ },
474+ wantErr: pickerCatalogueUnavailable,
475+ },
476+ {
477+ name: "epoch change",
478+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
479+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
480+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
481+ }}))
482+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
483+ require.True(t, ok)
484+ key := line.Key
485+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
486+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
487+ }}))
488+ return key
489+ },
490+ wantErr: pickerCatalogueUnknown,
491+ },
492+ {
493+ name: "session lifecycle replaced",
494+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
495+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
496+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
497+ }}))
498+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
499+ require.True(t, ok)
500+ key := line.Key
501+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
502+ pickerTestLocalObservation(now, pickerTestSession("alpha", 5, catalogue_Up)),
503+ }}))
504+ return key
505+ },
506+ wantErr: pickerCatalogueReplaced,
507+ },
508+ {
509+ name: "session gone",
510+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
511+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
512+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up)),
513+ }}))
514+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
515+ require.True(t, ok)
516+ key := line.Key
517+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{
518+ pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)),
519+ }}))
520+ return key
521+ },
522+ wantErr: pickerCatalogueGone,
523+ },
524+ {
525+ name: "broken session",
526+ build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string {
527+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
528+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)),
529+ }}))
530+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
531+ require.True(t, ok)
532+ return line.Key
533+ },
534+ wantErr: pickerCatalogueUnavailable,
535+ },
536+ }
537+
538+ for _, tt := range tests {
539+ t.Run(tt.name, func(t *testing.T) {
540+ catalogue, clock := pickerTestCatalogue(t)
541+ key := tt.build(t, catalogue, clock)
542+ request, err := catalogue.Resolve(key, base)
543+ if tt.wantErr != 0 {
544+ require.Error(t, err)
545+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
546+ return
547+ }
548+ require.NoError(t, err)
549+ require.NoError(t, request.Validate(), "a resolved request always validates")
550+ require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
551+ require.Equal(t, tt.wantAdmission, request.Admission)
552+ require.Equal(t, tt.wantLocal, request.Local)
553+ require.Equal(t, ports.BrokerEpoch(3), request.Epoch)
554+ require.Equal(t, base.Connection, request.Connection)
555+ require.Equal(t, base.Stream, request.Stream)
556+ })
557+ }
558+}
559+
560+func TestPickerCatalogueRemoteSelectionCarriesExactIdentity(t *testing.T) {
561+ clock := newSupervisorTestClock()
562+ now := clock.Now()
563+ catalogue, _ := pickerTestCatalogue(t)
564+ registration := pickerTestRegistration("user@arch", 1)
565+ lifecycle := pickerTestLifecycle(2)
566+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
567+ pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)),
568+ }}))
569+ line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a")
570+ require.True(t, ok)
571+
572+ request, err := catalogue.Resolve(line.Key, pickerTestBase())
573+ require.NoError(t, err)
574+ require.Equal(t, "user@arch", request.Endpoint)
575+ require.Equal(t, registration, request.Registration)
576+ require.Equal(t, protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "remote-a"}, request.Target)
577+ require.Equal(t, pickerTestPolicy(), request.Policy)
578+}
579+
580+func TestPickerCatalogueCreationResolution(t *testing.T) {
581+ clock := newSupervisorTestClock()
582+ now := clock.Now()
583+ base := pickerTestBase()
584+
585+ tests := []struct {
586+ name string
587+ local bool
588+ endpoint string
589+ kind pickerSelectionKind
590+ sessionName string
591+ snapshot func() ports.BrokerSnapshot
592+ wantErr pickerCatalogueErrorCode
593+ wantAdmission ports.BrokerStreamAdmission
594+ }{
595+ {
596+ name: "named creation on the local daemon",
597+ local: true,
598+ kind: pickerSelectionCreateNamed,
599+ sessionName: "fresh",
600+ snapshot: func() ports.BrokerSnapshot {
601+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}}
602+ },
603+ wantAdmission: ports.BrokerAdmissionCreateNamed,
604+ },
605+ {
606+ name: "ephemeral creation on a remote daemon",
607+ endpoint: "user@arch",
608+ kind: pickerSelectionCreateEphemeral,
609+ snapshot: func() ports.BrokerSnapshot {
610+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now)}}
611+ },
612+ wantAdmission: ports.BrokerAdmissionCreateEphemeral,
613+ },
614+ {
615+ name: "named creation without a name",
616+ local: true,
617+ kind: pickerSelectionCreateNamed,
618+ snapshot: func() ports.BrokerSnapshot {
619+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}}
620+ },
621+ wantErr: pickerCatalogueInvalidName,
622+ },
623+ {
624+ name: "creation on a missing remote daemon",
625+ endpoint: "user@arch",
626+ kind: pickerSelectionCreateEphemeral,
627+ snapshot: func() ports.BrokerSnapshot { return ports.BrokerSnapshot{Epoch: 3, Revision: 1} },
628+ wantErr: pickerCatalogueGone,
629+ },
630+ {
631+ name: "creation on an incompatible daemon",
632+ local: true,
633+ kind: pickerSelectionCreateEphemeral,
634+ snapshot: func() ports.BrokerSnapshot {
635+ observation := pickerTestLocalObservation(now)
636+ observation.ProtocolVersion = protocol.Version + 1
637+ return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}}
638+ },
639+ wantErr: pickerCatalogueIncompatible,
640+ },
641+ }
642+
643+ for _, tt := range tests {
644+ t.Run(tt.name, func(t *testing.T) {
645+ catalogue, _ := pickerTestCatalogue(t)
646+ require.True(t, catalogue.Apply(tt.snapshot()))
647+ request, err := catalogue.ResolveCreation(tt.local, tt.endpoint, tt.kind, tt.sessionName, base)
648+ if tt.wantErr != 0 {
649+ require.Error(t, err)
650+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
651+ return
652+ }
653+ require.NoError(t, err)
654+ require.NoError(t, request.Validate())
655+ require.Equal(t, tt.wantAdmission, request.Admission)
656+ })
657+ }
658+}
659+
660+func TestPickerCatalogueUnknownKeyRefused(t *testing.T) {
661+ catalogue, _ := pickerTestCatalogue(t)
662+ _, err := catalogue.Resolve("missing", pickerTestBase())
663+ require.Error(t, err)
664+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown))
665+}
666+
667+// TestPickerCatalogueEpochFenceOnCapturedRef pins the selection revision: a
668+// driver that captured a row's exact identity at one broker epoch may not
669+// resolve it after the broker process was replaced, even when the endpoint and
670+// session lifecycle happen to look the same.
671+func TestPickerCatalogueEpochFenceOnCapturedRef(t *testing.T) {
672+ clock := newSupervisorTestClock()
673+ now := clock.Now()
674+ catalogue, _ := pickerTestCatalogue(t)
675+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
676+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
677+ }}))
678+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
679+ require.True(t, ok)
680+ ref, ok := catalogue.Ref(line.Key)
681+ require.True(t, ok)
682+
683+ // A new broker epoch resynchronizes the catalogue; the captured identity is
684+ // fenced even though its key is recomputed identically.
685+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
686+ pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
687+ }}))
688+ _, err := catalogue.ResolveRef(ref, pickerTestBase())
689+ require.Error(t, err)
690+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueEpochStale))
691+}
692+
693+func TestPickerCatalogueRetiredRefFenceIsBounded(t *testing.T) {
694+ catalogue, _ := pickerTestCatalogue(t)
695+ base := pickerTestBase()
696+ const inserted = pickerCatalogueMaxRetiredRefs + 8
697+ keys := make([]string, 0, inserted)
698+ for i := range inserted {
699+ name := fmt.Sprintf("s-%d", i)
700+ session := pickerTestSession(name, byte(i%200+1), catalogue_Up)
701+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(i + 1), Daemons: []ports.BrokerDaemonObservation{
702+ pickerTestLocalObservation(time.Unix(1000, 0), session),
703+ }}))
704+ line, ok := pickerLineByLabel(catalogue.Lines(), name)
705+ require.True(t, ok)
706+ keys = append(keys, line.Key)
707+ }
708+ // The fence holds the exact bound: only the newest retired keys survive.
709+ require.Len(t, catalogue.retired, pickerCatalogueMaxRetiredRefs)
710+ require.Len(t, catalogue.retiredOrder, pickerCatalogueMaxRetiredRefs)
711+
712+ // A retained retired key still classifies as gone: its lifecycle left the
713+ // projection and no same-name replacement exists.
714+ retainedKey := keys[len(keys)-2]
715+ _, err := catalogue.Resolve(retainedKey, base)
716+ require.Error(t, err)
717+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone), "a retained retired key must classify as gone: %v", err)
718+
719+ // The oldest key was evicted from the bounded fence and is now unknown.
720+ evictedKey := keys[0]
721+ _, err = catalogue.Resolve(evictedKey, base)
722+ require.Error(t, err)
723+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown), "an evicted retired key must classify as unknown: %v", err)
724+}
725+
726+// TestPickerCatalogueRetiredRefFenceConcurrentApply exercises the retired-ref
727+// fence under -race: many goroutines fold distinct publications concurrently
728+// and the fence may never exceed its bound.
729+func TestPickerCatalogueRetiredRefFenceConcurrentApply(t *testing.T) {
730+ catalogue, _ := pickerTestCatalogue(t)
731+ const (
732+ workers = 8
733+ perWorker = 64
734+ )
735+ var wait sync.WaitGroup
736+ for worker := range workers {
737+ wait.Add(1)
738+ go func(worker int) {
739+ defer wait.Done()
740+ for i := range perWorker {
741+ var lifecycle domain.SessionLifecycleID
742+ lifecycle[0] = byte(i + 1)
743+ lifecycle[1] = byte(worker + 1)
744+ session := pickerTestSessionWithLifecycle(fmt.Sprintf("w%d-%d", worker, i), lifecycle)
745+ catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(worker*perWorker + i + 1), Daemons: []ports.BrokerDaemonObservation{
746+ pickerTestLocalObservation(time.Unix(1000, 0), session),
747+ }})
748+ }
749+ }(worker)
750+ }
751+ wait.Wait()
752+ require.LessOrEqual(t, len(catalogue.retired), pickerCatalogueMaxRetiredRefs)
753+ require.LessOrEqual(t, len(catalogue.retiredOrder), pickerCatalogueMaxRetiredRefs)
754+}
755+
756+// TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility pins the
757+// authoritative ordering: availability first, then the reachable version
758+// branch. An unobserved (ProtocolVersion 0) or unreachable daemon is never a
759+// version mismatch.
760+func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) {
761+ now := time.Unix(1000, 0)
762+ reachable := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation {
763+ observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
764+ if mutate != nil {
765+ mutate(&observation)
766+ }
767+ return observation
768+ }
769+ unobserved := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation {
770+ observation := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
771+ if mutate != nil {
772+ mutate(&observation)
773+ }
774+ return observation
775+ }
776+
777+ tests := []struct {
778+ name string
779+ observation ports.BrokerDaemonObservation
780+ fresh bool
781+ wantStatus protocol.PickerLineStatus
782+ wantReason string
783+ }{
784+ {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
785+ {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
786+ {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure},
787+ {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed},
788+ {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) {
789+ o.Availability = domain.RemoteAvailabilityUnreachable
790+ o.ProtocolVersion = protocol.Version + 1
791+ }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable},
792+ {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
793+ {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""},
794+ {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale},
795+ {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing},
796+ {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch},
797+ {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing},
798+ }
799+ for _, tt := range tests {
800+ t.Run(tt.name, func(t *testing.T) {
801+ require.Equal(t, tt.wantStatus, pickerObservationStatus(tt.observation, tt.fresh))
802+ require.Equal(t, tt.wantReason, pickerObservationReason(tt.observation, tt.fresh))
803+ })
804+ }
805+}
806+
807+// TestPickerCatalogueAvailabilityFirstResolution proves resolution checks
808+// availability before compatibility, so an unobserved or unreachable host
809+// refuses as unavailable rather than as a version mismatch.
810+func TestPickerCatalogueAvailabilityFirstResolution(t *testing.T) {
811+ now := time.Unix(1000, 0)
812+ tests := []struct {
813+ name string
814+ daemon ports.BrokerDaemonObservation
815+ wantErr pickerCatalogueErrorCode
816+ }{
817+ {
818+ name: "unobserved unknown refuses unavailable",
819+ daemon: pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)),
820+ wantErr: pickerCatalogueUnavailable,
821+ },
822+ {
823+ name: "unreachable observed mismatch refuses unavailable",
824+ daemon: func() ports.BrokerDaemonObservation {
825+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
826+ o.Availability = domain.RemoteAvailabilityUnreachable
827+ o.ProtocolVersion = protocol.Version + 1
828+ return o
829+ }(),
830+ wantErr: pickerCatalogueUnavailable,
831+ },
832+ {
833+ name: "auth failed refuses unavailable",
834+ daemon: func() ports.BrokerDaemonObservation {
835+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
836+ o.Availability = domain.RemoteAvailabilityAuthFailed
837+ return o
838+ }(),
839+ wantErr: pickerCatalogueUnavailable,
840+ },
841+ {
842+ name: "invalid response refuses unavailable",
843+ daemon: func() ports.BrokerDaemonObservation {
844+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
845+ o.Availability = domain.RemoteAvailabilityInvalidResponse
846+ return o
847+ }(),
848+ wantErr: pickerCatalogueUnavailable,
849+ },
850+ {
851+ name: "reachable unobserved refuses unavailable",
852+ daemon: func() ports.BrokerDaemonObservation {
853+ o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
854+ o.Availability = domain.RemoteAvailabilityReachable
855+ return o
856+ }(),
857+ wantErr: pickerCatalogueUnavailable,
858+ },
859+ {
860+ name: "reachable mismatch refuses incompatible",
861+ daemon: func() ports.BrokerDaemonObservation {
862+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
863+ o.ProtocolVersion = protocol.Version + 1
864+ return o
865+ }(),
866+ wantErr: pickerCatalogueIncompatible,
867+ },
868+ {
869+ name: "legacy incompatible availability refuses incompatible",
870+ daemon: func() ports.BrokerDaemonObservation {
871+ o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
872+ o.Availability = domain.RemoteAvailabilityIncompatible
873+ return o
874+ }(),
875+ wantErr: pickerCatalogueIncompatible,
876+ },
877+ }
878+ for _, tt := range tests {
879+ t.Run(tt.name, func(t *testing.T) {
880+ catalogue, _ := pickerTestCatalogue(t)
881+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
882+ line, ok := pickerLineByLabel(catalogue.Lines(), "alpha")
883+ require.True(t, ok)
884+ _, err := catalogue.Resolve(line.Key, pickerTestBase())
885+ require.Error(t, err)
886+ require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
887+ })
888+ }
889+}
890+
891+// TestPickerCatalogueRejectsInvalidSnapshot pins the projection trust
892+// boundary: a snapshot that fails its own contract is refused and the previous
893+// projection is kept intact.
894+func TestPickerCatalogueRejectsInvalidSnapshot(t *testing.T) {
895+ catalogue, clock := pickerTestCatalogue(t)
896+ valid := pickerTestSnapshot(3, 1, clock.Now(), "alpha")
897+ require.True(t, catalogue.Apply(valid))
898+ validLines := catalogue.Lines()
899+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision())
900+
901+ invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta")
902+ invalid.Daemons[0].Availability = 0 // out of range: availability is never zero
903+ require.False(t, catalogue.Apply(invalid), "an invalid snapshot is refused")
904+ require.Equal(t, ports.BrokerRevision(1), catalogue.Revision(), "the previous projection is kept")
905+ require.Equal(t, validLines, catalogue.Lines())
906+}
907+
908+// TestPickerCatalogueUnobservedDaemonProjects proves an unobserved daemon
909+// (Availability Unknown, ProtocolVersion 0, DisplayOrigin set) still projects a
910+// section and host row, classified as stale/refreshing rather than as a version
911+// mismatch.
912+func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) {
913+ catalogue, _ := pickerTestCatalogue(t)
914+ unobserved := pickerTestUnobservedObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up))
915+ require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}))
916+
917+ lines := catalogue.Lines()
918+ require.Equal(t, []string{"local"}, pickerSectionLabels(lines))
919+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines))
920+ host, ok := pickerLineByLabel(lines, "local")
921+ require.True(t, ok)
922+ require.Equal(t, protocol.PickerLineStatusStale, host.Status)
923+ require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch")
924+ session, ok := pickerLineByLabel(lines, "alpha")
925+ require.True(t, ok)
926+ require.True(t, session.Focusable)
927+ require.Zero(t, session.Actions, "an unobserved host row is inspectable only")
928+ require.True(t, session.Dim)
929+}
930+
931+// TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels
932+// never fall back to a raw endpoint.
933+func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) {
934+ raw := pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))
935+ raw.DisplayOrigin = ""
936+ require.Equal(t, pickerCatalogueOriginFallback, pickerOriginLabel(raw))
937+ require.NotContains(t, pickerOriginLabel(raw), "user@arch")
938+
939+ require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0))))
940+ require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0))))
941+}
A internal/usecase/client/picker_controller.go +444 -0
@@ -0,0 +1,444 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/ui"
12+)
13+
14+// Client-owned picker controller (Plan 001 P5.2b, offline and unactivated).
15+//
16+// This file is the narrow seam between the autonomous supervisor and the
17+// client-owned catalogue. The supervisor owns raw mode, the single terminal
18+// input lifetime, and the broker service; the controller owns the catalogue
19+// projection, the presentation model (reusing the picker package's cursor,
20+// search, sort, and rendering machinery), and the input decisions.
21+//
22+// Two invariants make the picker autonomous:
23+//
24+// - Zero session bytes. The controller has no session, stream, or PTY
25+// reference. A consumed terminal read is decoded into picker events and
26+// applied to the model; nothing is ever written toward a daemon. A
27+// selection resolves to a ports.BrokerOpenStreamRequest value and stops
28+// there.
29+// - No serving-daemon catalogue. Rows come from broker snapshots through
30+// pickerCatalogue, so navigation needs no serving attachment.
31+//
32+// input ownership reuses the existing pickerConsumer, so the supervisor's one
33+// terminal input lifetime feeds exactly one owner and the picker takes
34+// precedence: while the controller owns input every decoded batch is consumed
35+// and there is no fallback to a session pipeline.
36+
37+// pickerHost is the supervisor's view of the client-owned picker. The
38+// supervisor applies each broker publication and hands it every terminal read
39+// from its single input lifetime; the picker decides ownership.
40+type pickerHost interface {
41+ pickerInputConsumer
42+ // ApplySnapshot folds one broker publication into the catalogue and
43+ // refreshes the presentation immediately.
44+ ApplySnapshot(ports.BrokerSnapshot)
45+}
46+
47+// pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
48+// returns true when the picker owned and consumed the read; false leaves the
49+// read to the supervisor's ordinary (P5.1a) handling.
50+type pickerInputConsumer interface {
51+ ConsumeTerminalRead(data []byte) bool
52+}
53+
54+const (
55+ // pickerGeneration identifies the single client-owned picker generation.
56+ // It never changes while the controller lives; the presentation epoch is
57+ // the broker epoch, not this value.
58+ pickerGeneration = uint64(1)
59+ // pickerNoticeLifetime bounds how long one catalogue notice is displayed.
60+ pickerNoticeLifetime = 4 * time.Second
61+)
62+
63+// pickerController owns one client-owned picker: catalogue, model, renderer,
64+// bounded notices, and input ownership. It is safe for concurrent use, so the
65+// supervisor's input lifetime and its run path may both reach it.
66+type pickerController struct {
67+ clock ports.Clock
68+ catalogue *pickerCatalogue
69+
70+ mu sync.Mutex
71+ loop *pickerLoop
72+ consumer pickerConsumer
73+ renderer *pickerRenderer
74+ notices ui.ToastManager
75+ interaction uint64
76+ ownsInput bool
77+ lastOp pickerOp
78+ // lastCommitKey is the catalogue key captured with the pending commit
79+ // decision, so TakeOp can hand a driver the exact committed row.
80+ lastCommitKey string
81+}
82+
83+// newPickerController builds a picker over an empty catalogue. The picker owns
84+// input from the start: P5.2b presents it for the whole picker presentation and
85+// has no session pipeline to defer to.
86+func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController {
87+ if supervisorNil(clock) {
88+ clock = systemClock{}
89+ }
90+ controller := &pickerController{
91+ clock: clock,
92+ catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
93+ renderer: newPickerRenderer(),
94+ ownsInput: true,
95+ }
96+ controller.consumer.setOwned(pickerGeneration, pickerGeneration)
97+ return controller
98+}
99+
100+// Catalogue exposes the projection for a driver that needs to resolve a
101+// selection or inspect the applied revision.
102+func (p *pickerController) Catalogue() *pickerCatalogue {
103+ if p == nil {
104+ return nil
105+ }
106+ return p.catalogue
107+}
108+
109+// SetOwnsInput toggles input ownership. It is the future attach slice's release
110+// boundary; P5.2b keeps it owned for the whole run.
111+func (p *pickerController) SetOwnsInput(owns bool) {
112+ if p == nil {
113+ return
114+ }
115+ p.mu.Lock()
116+ defer p.mu.Unlock()
117+ if owns == p.ownsInput {
118+ return
119+ }
120+ p.ownsInput = owns
121+ if owns {
122+ p.consumer.setOwned(pickerGeneration, pickerGeneration)
123+ return
124+ }
125+ p.consumer.clear()
126+}
127+
128+// ApplySnapshot folds one broker publication into the catalogue and rebuilds
129+// the presentation synchronously, so the next Render reflects it immediately.
130+func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
131+ if p == nil || p.catalogue == nil {
132+ return
133+ }
134+ if !p.catalogue.Apply(snapshot) {
135+ // A publication that fails the broker snapshot contract is rejected as
136+ // a whole (the previous projection is kept) and surfaced as a bounded
137+ // notice instead of silently vanishing. A merely stale publication
138+ // validates and is ignored without a notice.
139+ if snapshot.Validate() != nil {
140+ p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected")
141+ }
142+ return
143+ }
144+ p.mu.Lock()
145+ defer p.mu.Unlock()
146+ p.rebuildLocked()
147+ p.offerDiagnosticsLocked()
148+}
149+
150+// ConsumeTerminalRead decodes one terminal read while the picker owns input,
151+// applies it to the model, and records the presentation decision (with any
152+// committed key) atomically under the controller lock. It reports whether the
153+// read was consumed: a false result means the supervisor keeps its ordinary
154+// handling. It never writes a session.
155+func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
156+ if p == nil {
157+ return false
158+ }
159+ return p.handleRead(data)
160+}
161+
162+// recordOpLocked accumulates one read's presentation decision. A commit also
163+// captures the committed row key under the same lock, so the key and the
164+// decision can never disagree.
165+func (p *pickerController) recordOpLocked(op pickerOp) {
166+ if op.commit {
167+ p.lastCommitKey = p.commitKeyLocked()
168+ }
169+ p.lastOp = mergePickerOps(p.lastOp, op)
170+}
171+
172+// commitKeyLocked returns the opaque catalogue key of the row under the cursor
173+// for a commit, or the empty string when no committable row is selected.
174+func (p *pickerController) commitKeyLocked() string {
175+ if p.loop == nil {
176+ return ""
177+ }
178+ selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0)
179+ if !ok {
180+ return ""
181+ }
182+ return selection.Key
183+}
184+
185+// TakeOp returns and clears the accumulated presentation decision since the
186+// last call, together with the catalogue key of the row the commit decision
187+// selected at the moment it was recorded. Resolving that key with ResolveKey
188+// attaches exactly the committed row: TakeOp cannot observe a cursor that a
189+// concurrent ApplySnapshot moved afterwards. The key is empty when the
190+// decision carries no commit or no committable row.
191+func (p *pickerController) TakeOp() (pickerOp, string) {
192+ if p == nil {
193+ return pickerOp{}, ""
194+ }
195+ p.mu.Lock()
196+ defer p.mu.Unlock()
197+ op := p.lastOp
198+ key := p.lastCommitKey
199+ p.lastOp = pickerOp{}
200+ p.lastCommitKey = ""
201+ return op, key
202+}
203+
204+// FlushPending resolves a withheld escape or UTF-8 prefix once its
205+// disambiguation window expired. The bound belongs to the driver; the picker
206+// only decides what the prefix means.
207+func (p *pickerController) FlushPending() {
208+ if p == nil {
209+ return
210+ }
211+ p.mu.Lock()
212+ defer p.mu.Unlock()
213+ if !p.ownsInput {
214+ return
215+ }
216+ outcome, consumed := p.consumer.flushPending()
217+ if !consumed || p.loop == nil {
218+ return
219+ }
220+ if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
221+ return
222+ }
223+ op, _ := applyPickerBatch(p.loop, outcome.events)
224+ p.recordOpLocked(op)
225+}
226+
227+// OwnsInput reports whether the picker currently owns terminal input.
228+func (p *pickerController) OwnsInput() bool {
229+ if p == nil {
230+ return false
231+ }
232+ p.mu.Lock()
233+ defer p.mu.Unlock()
234+ return p.ownsInput
235+}
236+
237+// Render composes the current picker modal into terminal bytes for one display
238+// size. It is a pure read of the current model: it changes no state and writes
239+// nothing.
240+func (p *pickerController) Render(size domain.Size) []byte {
241+ if p == nil {
242+ return nil
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ if p.loop == nil {
247+ return nil
248+ }
249+ return p.renderer.render(p.loop, size, emptyPickerPreview())
250+}
251+
252+// RenderNotice composes the newest bounded catalogue notice as a client-local
253+// toast. It reuses the client's existing toast rendering and returns nil when
254+// nothing is displayed.
255+func (p *pickerController) RenderNotice(size domain.Size) []byte {
256+ if p == nil {
257+ return nil
258+ }
259+ p.mu.Lock()
260+ active := p.notices.Active(p.clock.Now())
261+ p.mu.Unlock()
262+ if len(active) == 0 {
263+ return nil
264+ }
265+ // ToastManager.Active returns toasts in map-iteration order, so "the last
266+ // one" is arbitrary and can change between frames. Select the newest by
267+ // ShownAt, tie-broken deterministically by ID.
268+ newest := active[0]
269+ for _, toast := range active[1:] {
270+ if toast.ShownAt.After(newest.ShownAt) ||
271+ (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) {
272+ newest = toast
273+ }
274+ }
275+ if newest.Message == "" {
276+ return nil
277+ }
278+ var buffer bytes.Buffer
279+ if _, err := drawClientToast(&buffer, size, newest.Message); err != nil {
280+ return nil
281+ }
282+ return buffer.Bytes()
283+}
284+
285+// CursorKey reports the opaque key of the row under the cursor, or false when
286+// nothing qualifies. It is the selection displayed now; a commit decision is
287+// captured with TakeOp instead so a concurrent publication cannot move it.
288+func (p *pickerController) CursorKey() (string, bool) {
289+ if p == nil {
290+ return "", false
291+ }
292+ p.mu.Lock()
293+ defer p.mu.Unlock()
294+ key := p.commitKeyLocked()
295+ if key == "" {
296+ return "", false
297+ }
298+ return key, true
299+}
300+
301+// Resolve revalidates the row displayed now and returns the exact broker
302+// stream request. A row the picker shows but does not admit an action still
303+// resolves, so committing an inspectable row yields a typed catalogue refusal
304+// (stale, incompatible, unavailable) instead of a generic no-selection. A
305+// refusal is bounded and is also surfaced as a notice.
306+//
307+// Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
308+// can move the cursor between the caller's commit decision and this call. A
309+// driver that is committing a specific row must capture that row's key with
310+// the decision (TakeOp) and resolve it with ResolveKey instead.
311+func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
312+ if p == nil {
313+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
314+ }
315+ key, ok := p.displayedKey()
316+ if !ok {
317+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
318+ p.offerNotice("picker-refusal", err.Error())
319+ return ports.BrokerOpenStreamRequest{}, err
320+ }
321+ return p.ResolveKey(key, base)
322+}
323+
324+// ResolveKey revalidates exactly the supplied catalogue key against the latest
325+// applied snapshot and returns the exact broker stream request. It is the
326+// commit path: the key captured atomically with the commit decision (see
327+// TakeOp) resolves the row the user committed even if the cursor has since
328+// moved. A refusal is bounded and is also surfaced as a notice.
329+func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
330+ if p == nil || p.catalogue == nil {
331+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
332+ if p != nil {
333+ p.offerNotice("picker-refusal", err.Error())
334+ }
335+ return ports.BrokerOpenStreamRequest{}, err
336+ }
337+ request, err := p.catalogue.Resolve(key, base)
338+ if err != nil {
339+ p.offerNotice("picker-refusal", err.Error())
340+ return ports.BrokerOpenStreamRequest{}, err
341+ }
342+ return request, nil
343+}
344+
345+// displayedKey names the row under the cursor, whether or not it admits an
346+// action, and maps its presentation key back to the source identity.
347+func (p *pickerController) displayedKey() (string, bool) {
348+ p.mu.Lock()
349+ defer p.mu.Unlock()
350+ if p.loop == nil || p.loop.model == nil {
351+ return "", false
352+ }
353+ line, ok := p.loop.model.Cursor()
354+ if !ok {
355+ return "", false
356+ }
357+ identity, ok := p.loop.rows[line.Key]
358+ if !ok {
359+ return "", false
360+ }
361+ return identity.key, true
362+}
363+
364+func (p *pickerController) handleRead(data []byte) bool {
365+ p.mu.Lock()
366+ defer p.mu.Unlock()
367+ if !p.ownsInput {
368+ return false
369+ }
370+ if len(data) != 0 {
371+ data = append([]byte(nil), data...)
372+ }
373+ outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
374+ if !consumed {
375+ return false
376+ }
377+ if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
378+ // The picker owns input before its first publication: the batch is
379+ // consumed and dropped, never forwarded.
380+ return true
381+ }
382+ op, _ := applyPickerBatch(p.loop, outcome.events)
383+ p.recordOpLocked(op)
384+ return true
385+}
386+
387+func (p *pickerController) rebuildLocked() {
388+ lines, cursor := p.catalogue.projection()
389+ revision := uint64(p.catalogue.Revision())
390+ if p.loop == nil {
391+ p.interaction++
392+ p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{
393+ InteractionID: p.interaction,
394+ SourceID: pickerCatalogueSourceID,
395+ SourceRevision: revision,
396+ Lines: lines,
397+ Cursor: cursor,
398+ }, protocol.PickerIntentNavigation, defaultPickerSort())
399+ return
400+ }
401+ p.loop.replaceLines(protocol.PickerSnapshot{
402+ InteractionID: p.interaction,
403+ SourceID: pickerCatalogueSourceID,
404+ SourceRevision: revision,
405+ Lines: lines,
406+ Cursor: cursor,
407+ })
408+}
409+
410+// offerDiagnosticsLocked surfaces every host that is not a clean, fresh,
411+// compatible, reachable daemon as a bounded toast. The toast manager keeps one
412+// entry per host key, so the notices stay bounded regardless of update rate.
413+func (p *pickerController) offerDiagnosticsLocked() {
414+ for _, diagnostic := range p.catalogue.diagnostics() {
415+ if diagnostic.Message == "" {
416+ continue
417+ }
418+ p.notices.Show(p.clock.Now(), ui.Toast{
419+ ID: "picker-host:" + diagnostic.Key,
420+ Message: diagnostic.Origin + ": " + diagnostic.Message,
421+ Anchor: domain.AnchorCenter,
422+ Duration: pickerNoticeLifetime,
423+ })
424+ }
425+}
426+
427+func (p *pickerController) offerNotice(id, message string) {
428+ if message == "" {
429+ return
430+ }
431+ p.mu.Lock()
432+ p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime})
433+ p.mu.Unlock()
434+}
435+
436+// mergePickerOps accumulates two presentation decisions from one read. Close
437+// wins because it retires the interaction; commit and kill both remain.
438+func mergePickerOps(a, b pickerOp) pickerOp {
439+ return pickerOp{
440+ commit: a.commit || b.commit,
441+ kill: a.kill || b.kill,
442+ close: a.close || b.close,
443+ }
444+}
@@ -0,0 +1,444 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/usecase/ui"
12+)
13+
14+// Client-owned picker controller (Plan 001 P5.2b, offline and unactivated).
15+//
16+// This file is the narrow seam between the autonomous supervisor and the
17+// client-owned catalogue. The supervisor owns raw mode, the single terminal
18+// input lifetime, and the broker service; the controller owns the catalogue
19+// projection, the presentation model (reusing the picker package's cursor,
20+// search, sort, and rendering machinery), and the input decisions.
21+//
22+// Two invariants make the picker autonomous:
23+//
24+// - Zero session bytes. The controller has no session, stream, or PTY
25+// reference. A consumed terminal read is decoded into picker events and
26+// applied to the model; nothing is ever written toward a daemon. A
27+// selection resolves to a ports.BrokerOpenStreamRequest value and stops
28+// there.
29+// - No serving-daemon catalogue. Rows come from broker snapshots through
30+// pickerCatalogue, so navigation needs no serving attachment.
31+//
32+// input ownership reuses the existing pickerConsumer, so the supervisor's one
33+// terminal input lifetime feeds exactly one owner and the picker takes
34+// precedence: while the controller owns input every decoded batch is consumed
35+// and there is no fallback to a session pipeline.
36+
37+// pickerHost is the supervisor's view of the client-owned picker. The
38+// supervisor applies each broker publication and hands it every terminal read
39+// from its single input lifetime; the picker decides ownership.
40+type pickerHost interface {
41+ pickerInputConsumer
42+ // ApplySnapshot folds one broker publication into the catalogue and
43+ // refreshes the presentation immediately.
44+ ApplySnapshot(ports.BrokerSnapshot)
45+}
46+
47+// pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead
48+// returns true when the picker owned and consumed the read; false leaves the
49+// read to the supervisor's ordinary (P5.1a) handling.
50+type pickerInputConsumer interface {
51+ ConsumeTerminalRead(data []byte) bool
52+}
53+
54+const (
55+ // pickerGeneration identifies the single client-owned picker generation.
56+ // It never changes while the controller lives; the presentation epoch is
57+ // the broker epoch, not this value.
58+ pickerGeneration = uint64(1)
59+ // pickerNoticeLifetime bounds how long one catalogue notice is displayed.
60+ pickerNoticeLifetime = 4 * time.Second
61+)
62+
63+// pickerController owns one client-owned picker: catalogue, model, renderer,
64+// bounded notices, and input ownership. It is safe for concurrent use, so the
65+// supervisor's input lifetime and its run path may both reach it.
66+type pickerController struct {
67+ clock ports.Clock
68+ catalogue *pickerCatalogue
69+
70+ mu sync.Mutex
71+ loop *pickerLoop
72+ consumer pickerConsumer
73+ renderer *pickerRenderer
74+ notices ui.ToastManager
75+ interaction uint64
76+ ownsInput bool
77+ lastOp pickerOp
78+ // lastCommitKey is the catalogue key captured with the pending commit
79+ // decision, so TakeOp can hand a driver the exact committed row.
80+ lastCommitKey string
81+}
82+
83+// newPickerController builds a picker over an empty catalogue. The picker owns
84+// input from the start: P5.2b presents it for the whole picker presentation and
85+// has no session pipeline to defer to.
86+func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController {
87+ if supervisorNil(clock) {
88+ clock = systemClock{}
89+ }
90+ controller := &pickerController{
91+ clock: clock,
92+ catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
93+ renderer: newPickerRenderer(),
94+ ownsInput: true,
95+ }
96+ controller.consumer.setOwned(pickerGeneration, pickerGeneration)
97+ return controller
98+}
99+
100+// Catalogue exposes the projection for a driver that needs to resolve a
101+// selection or inspect the applied revision.
102+func (p *pickerController) Catalogue() *pickerCatalogue {
103+ if p == nil {
104+ return nil
105+ }
106+ return p.catalogue
107+}
108+
109+// SetOwnsInput toggles input ownership. It is the future attach slice's release
110+// boundary; P5.2b keeps it owned for the whole run.
111+func (p *pickerController) SetOwnsInput(owns bool) {
112+ if p == nil {
113+ return
114+ }
115+ p.mu.Lock()
116+ defer p.mu.Unlock()
117+ if owns == p.ownsInput {
118+ return
119+ }
120+ p.ownsInput = owns
121+ if owns {
122+ p.consumer.setOwned(pickerGeneration, pickerGeneration)
123+ return
124+ }
125+ p.consumer.clear()
126+}
127+
128+// ApplySnapshot folds one broker publication into the catalogue and rebuilds
129+// the presentation synchronously, so the next Render reflects it immediately.
130+func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) {
131+ if p == nil || p.catalogue == nil {
132+ return
133+ }
134+ if !p.catalogue.Apply(snapshot) {
135+ // A publication that fails the broker snapshot contract is rejected as
136+ // a whole (the previous projection is kept) and surfaced as a bounded
137+ // notice instead of silently vanishing. A merely stale publication
138+ // validates and is ignored without a notice.
139+ if snapshot.Validate() != nil {
140+ p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected")
141+ }
142+ return
143+ }
144+ p.mu.Lock()
145+ defer p.mu.Unlock()
146+ p.rebuildLocked()
147+ p.offerDiagnosticsLocked()
148+}
149+
150+// ConsumeTerminalRead decodes one terminal read while the picker owns input,
151+// applies it to the model, and records the presentation decision (with any
152+// committed key) atomically under the controller lock. It reports whether the
153+// read was consumed: a false result means the supervisor keeps its ordinary
154+// handling. It never writes a session.
155+func (p *pickerController) ConsumeTerminalRead(data []byte) bool {
156+ if p == nil {
157+ return false
158+ }
159+ return p.handleRead(data)
160+}
161+
162+// recordOpLocked accumulates one read's presentation decision. A commit also
163+// captures the committed row key under the same lock, so the key and the
164+// decision can never disagree.
165+func (p *pickerController) recordOpLocked(op pickerOp) {
166+ if op.commit {
167+ p.lastCommitKey = p.commitKeyLocked()
168+ }
169+ p.lastOp = mergePickerOps(p.lastOp, op)
170+}
171+
172+// commitKeyLocked returns the opaque catalogue key of the row under the cursor
173+// for a commit, or the empty string when no committable row is selected.
174+func (p *pickerController) commitKeyLocked() string {
175+ if p.loop == nil {
176+ return ""
177+ }
178+ selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0)
179+ if !ok {
180+ return ""
181+ }
182+ return selection.Key
183+}
184+
185+// TakeOp returns and clears the accumulated presentation decision since the
186+// last call, together with the catalogue key of the row the commit decision
187+// selected at the moment it was recorded. Resolving that key with ResolveKey
188+// attaches exactly the committed row: TakeOp cannot observe a cursor that a
189+// concurrent ApplySnapshot moved afterwards. The key is empty when the
190+// decision carries no commit or no committable row.
191+func (p *pickerController) TakeOp() (pickerOp, string) {
192+ if p == nil {
193+ return pickerOp{}, ""
194+ }
195+ p.mu.Lock()
196+ defer p.mu.Unlock()
197+ op := p.lastOp
198+ key := p.lastCommitKey
199+ p.lastOp = pickerOp{}
200+ p.lastCommitKey = ""
201+ return op, key
202+}
203+
204+// FlushPending resolves a withheld escape or UTF-8 prefix once its
205+// disambiguation window expired. The bound belongs to the driver; the picker
206+// only decides what the prefix means.
207+func (p *pickerController) FlushPending() {
208+ if p == nil {
209+ return
210+ }
211+ p.mu.Lock()
212+ defer p.mu.Unlock()
213+ if !p.ownsInput {
214+ return
215+ }
216+ outcome, consumed := p.consumer.flushPending()
217+ if !consumed || p.loop == nil {
218+ return
219+ }
220+ if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
221+ return
222+ }
223+ op, _ := applyPickerBatch(p.loop, outcome.events)
224+ p.recordOpLocked(op)
225+}
226+
227+// OwnsInput reports whether the picker currently owns terminal input.
228+func (p *pickerController) OwnsInput() bool {
229+ if p == nil {
230+ return false
231+ }
232+ p.mu.Lock()
233+ defer p.mu.Unlock()
234+ return p.ownsInput
235+}
236+
237+// Render composes the current picker modal into terminal bytes for one display
238+// size. It is a pure read of the current model: it changes no state and writes
239+// nothing.
240+func (p *pickerController) Render(size domain.Size) []byte {
241+ if p == nil {
242+ return nil
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ if p.loop == nil {
247+ return nil
248+ }
249+ return p.renderer.render(p.loop, size, emptyPickerPreview())
250+}
251+
252+// RenderNotice composes the newest bounded catalogue notice as a client-local
253+// toast. It reuses the client's existing toast rendering and returns nil when
254+// nothing is displayed.
255+func (p *pickerController) RenderNotice(size domain.Size) []byte {
256+ if p == nil {
257+ return nil
258+ }
259+ p.mu.Lock()
260+ active := p.notices.Active(p.clock.Now())
261+ p.mu.Unlock()
262+ if len(active) == 0 {
263+ return nil
264+ }
265+ // ToastManager.Active returns toasts in map-iteration order, so "the last
266+ // one" is arbitrary and can change between frames. Select the newest by
267+ // ShownAt, tie-broken deterministically by ID.
268+ newest := active[0]
269+ for _, toast := range active[1:] {
270+ if toast.ShownAt.After(newest.ShownAt) ||
271+ (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) {
272+ newest = toast
273+ }
274+ }
275+ if newest.Message == "" {
276+ return nil
277+ }
278+ var buffer bytes.Buffer
279+ if _, err := drawClientToast(&buffer, size, newest.Message); err != nil {
280+ return nil
281+ }
282+ return buffer.Bytes()
283+}
284+
285+// CursorKey reports the opaque key of the row under the cursor, or false when
286+// nothing qualifies. It is the selection displayed now; a commit decision is
287+// captured with TakeOp instead so a concurrent publication cannot move it.
288+func (p *pickerController) CursorKey() (string, bool) {
289+ if p == nil {
290+ return "", false
291+ }
292+ p.mu.Lock()
293+ defer p.mu.Unlock()
294+ key := p.commitKeyLocked()
295+ if key == "" {
296+ return "", false
297+ }
298+ return key, true
299+}
300+
301+// Resolve revalidates the row displayed now and returns the exact broker
302+// stream request. A row the picker shows but does not admit an action still
303+// resolves, so committing an inspectable row yields a typed catalogue refusal
304+// (stale, incompatible, unavailable) instead of a generic no-selection. A
305+// refusal is bounded and is also surfaced as a notice.
306+//
307+// Resolve reads the mutable presentation model, so a concurrent ApplySnapshot
308+// can move the cursor between the caller's commit decision and this call. A
309+// driver that is committing a specific row must capture that row's key with
310+// the decision (TakeOp) and resolve it with ResolveKey instead.
311+func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
312+ if p == nil {
313+ return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
314+ }
315+ key, ok := p.displayedKey()
316+ if !ok {
317+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"}
318+ p.offerNotice("picker-refusal", err.Error())
319+ return ports.BrokerOpenStreamRequest{}, err
320+ }
321+ return p.ResolveKey(key, base)
322+}
323+
324+// ResolveKey revalidates exactly the supplied catalogue key against the latest
325+// applied snapshot and returns the exact broker stream request. It is the
326+// commit path: the key captured atomically with the commit decision (see
327+// TakeOp) resolves the row the user committed even if the cursor has since
328+// moved. A refusal is bounded and is also surfaced as a notice.
329+func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) {
330+ if p == nil || p.catalogue == nil {
331+ err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"}
332+ if p != nil {
333+ p.offerNotice("picker-refusal", err.Error())
334+ }
335+ return ports.BrokerOpenStreamRequest{}, err
336+ }
337+ request, err := p.catalogue.Resolve(key, base)
338+ if err != nil {
339+ p.offerNotice("picker-refusal", err.Error())
340+ return ports.BrokerOpenStreamRequest{}, err
341+ }
342+ return request, nil
343+}
344+
345+// displayedKey names the row under the cursor, whether or not it admits an
346+// action, and maps its presentation key back to the source identity.
347+func (p *pickerController) displayedKey() (string, bool) {
348+ p.mu.Lock()
349+ defer p.mu.Unlock()
350+ if p.loop == nil || p.loop.model == nil {
351+ return "", false
352+ }
353+ line, ok := p.loop.model.Cursor()
354+ if !ok {
355+ return "", false
356+ }
357+ identity, ok := p.loop.rows[line.Key]
358+ if !ok {
359+ return "", false
360+ }
361+ return identity.key, true
362+}
363+
364+func (p *pickerController) handleRead(data []byte) bool {
365+ p.mu.Lock()
366+ defer p.mu.Unlock()
367+ if !p.ownsInput {
368+ return false
369+ }
370+ if len(data) != 0 {
371+ data = append([]byte(nil), data...)
372+ }
373+ outcome, consumed := p.consumer.consume(terminalReadResult{data: data})
374+ if !consumed {
375+ return false
376+ }
377+ if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration {
378+ // The picker owns input before its first publication: the batch is
379+ // consumed and dropped, never forwarded.
380+ return true
381+ }
382+ op, _ := applyPickerBatch(p.loop, outcome.events)
383+ p.recordOpLocked(op)
384+ return true
385+}
386+
387+func (p *pickerController) rebuildLocked() {
388+ lines, cursor := p.catalogue.projection()
389+ revision := uint64(p.catalogue.Revision())
390+ if p.loop == nil {
391+ p.interaction++
392+ p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{
393+ InteractionID: p.interaction,
394+ SourceID: pickerCatalogueSourceID,
395+ SourceRevision: revision,
396+ Lines: lines,
397+ Cursor: cursor,
398+ }, protocol.PickerIntentNavigation, defaultPickerSort())
399+ return
400+ }
401+ p.loop.replaceLines(protocol.PickerSnapshot{
402+ InteractionID: p.interaction,
403+ SourceID: pickerCatalogueSourceID,
404+ SourceRevision: revision,
405+ Lines: lines,
406+ Cursor: cursor,
407+ })
408+}
409+
410+// offerDiagnosticsLocked surfaces every host that is not a clean, fresh,
411+// compatible, reachable daemon as a bounded toast. The toast manager keeps one
412+// entry per host key, so the notices stay bounded regardless of update rate.
413+func (p *pickerController) offerDiagnosticsLocked() {
414+ for _, diagnostic := range p.catalogue.diagnostics() {
415+ if diagnostic.Message == "" {
416+ continue
417+ }
418+ p.notices.Show(p.clock.Now(), ui.Toast{
419+ ID: "picker-host:" + diagnostic.Key,
420+ Message: diagnostic.Origin + ": " + diagnostic.Message,
421+ Anchor: domain.AnchorCenter,
422+ Duration: pickerNoticeLifetime,
423+ })
424+ }
425+}
426+
427+func (p *pickerController) offerNotice(id, message string) {
428+ if message == "" {
429+ return
430+ }
431+ p.mu.Lock()
432+ p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime})
433+ p.mu.Unlock()
434+}
435+
436+// mergePickerOps accumulates two presentation decisions from one read. Close
437+// wins because it retires the interaction; commit and kill both remain.
438+func mergePickerOps(a, b pickerOp) pickerOp {
439+ return pickerOp{
440+ commit: a.commit || b.commit,
441+ kill: a.kill || b.kill,
442+ close: a.close || b.close,
443+ }
444+}
A internal/usecase/client/picker_controller_test.go +624 -0
@@ -0,0 +1,624 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/usecase/ui"
17+)
18+
19+// Client-owned picker controller tests (Plan 001 P5.2b): rendering, local
20+// search/cursor behaviour across live updates, and the input-ownership proof
21+// that picker input never reaches a session.
22+
23+// pickerChunkReader yields pre-pushed chunks to the supervisor's single input
24+// lifetime. push blocks until the lifetime reads the chunk, so a test can
25+// order a read deterministically without a sleep.
26+type pickerChunkReader struct {
27+ chunks chan []byte
28+}
29+
30+func newPickerChunkReader() *pickerChunkReader {
31+ return &pickerChunkReader{chunks: make(chan []byte)}
32+}
33+
34+func (r *pickerChunkReader) push(data []byte) {
35+ r.chunks <- append([]byte(nil), data...)
36+}
37+
38+func (r *pickerChunkReader) close() { close(r.chunks) }
39+
40+func (r *pickerChunkReader) Read(buffer []byte) (int, error) {
41+ chunk, ok := <-r.chunks
42+ if !ok {
43+ return 0, io.EOF
44+ }
45+ return copy(buffer, chunk), nil
46+}
47+
48+// pickerInputSpy forwards each read to the real consumer and signals that it
49+// was delivered, so a test can wait exactly for consumption.
50+type pickerInputSpy struct {
51+ inner pickerInputConsumer
52+ done chan struct{}
53+}
54+
55+func newPickerInputSpy(inner pickerInputConsumer) *pickerInputSpy {
56+ return &pickerInputSpy{inner: inner, done: make(chan struct{}, 1)}
57+}
58+
59+func (s *pickerInputSpy) ConsumeTerminalRead(data []byte) bool {
60+ consumed := s.inner.ConsumeTerminalRead(data)
61+ select {
62+ case s.done <- struct{}{}:
63+ default:
64+ }
65+ return consumed
66+}
67+
68+func (s *pickerInputSpy) await(t *testing.T) {
69+ t.Helper()
70+ select {
71+ case <-s.done:
72+ case <-time.After(5 * time.Second):
73+ t.Fatal("terminal input was not consumed")
74+ }
75+}
76+
77+// pickerRecordingTerminal records every byte written to the terminal output,
78+// which is where a leaked picker key would surface if it reached a session.
79+type pickerRecordingTerminal struct {
80+ in io.Reader
81+ out bytes.Buffer
82+ mu sync.Mutex
83+ enters int
84+}
85+
86+func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {
87+ t.mu.Lock()
88+ t.enters++
89+ t.mu.Unlock()
90+ return func() error { return nil }, nil
91+}
92+
93+func (t *pickerRecordingTerminal) Geometry() (domain.Geometry, error) {
94+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
95+}
96+
97+func (t *pickerRecordingTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
98+
99+func (t *pickerRecordingTerminal) In() io.Reader { return t.in }
100+
101+func (t *pickerRecordingTerminal) Out() io.Writer { return &t.out }
102+
103+func (t *pickerRecordingTerminal) Flush() error { return nil }
104+
105+func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, now time.Time, sessions ...string) ports.BrokerSnapshot {
106+ catalogSessions := make([]catalogue.RemoteCatalogSession, 0, len(sessions))
107+ for i, name := range sessions {
108+ catalogSessions = append(catalogSessions, pickerTestSession(name, byte(i+1), catalogue_Up))
109+ }
110+ return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
111+ pickerTestLocalObservation(now, catalogSessions...),
112+ }}
113+}
114+
115+func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
116+ t.Helper()
117+ clock := newSupervisorTestClock()
118+ return newPickerController(clock, pickerTestFreshness), clock
119+}
120+
121+func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
122+ t.Helper()
123+ line, ok := pickerLineByLabel(controller.Catalogue().Lines(), label)
124+ require.True(t, ok, "row %q must be projected", label)
125+ return line.Key
126+}
127+
128+func mustCursorKey(t *testing.T, controller *pickerController) string {
129+ t.Helper()
130+ key, ok := controller.CursorKey()
131+ require.True(t, ok, "the picker must have a selected row")
132+ return key
133+}
134+
135+func (p *pickerController) modelQuery(t *testing.T) string {
136+ t.Helper()
137+ p.mu.Lock()
138+ defer p.mu.Unlock()
139+ require.NotNil(t, p.loop)
140+ return p.loop.model.Query()
141+}
142+
143+func (p *pickerController) modelSearchActive(t *testing.T) bool {
144+ t.Helper()
145+ p.mu.Lock()
146+ defer p.mu.Unlock()
147+ require.NotNil(t, p.loop)
148+ return p.loop.model.SearchActive()
149+}
150+
151+func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {
152+ controller, clock := pickerTestController(t)
153+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
154+ firstRender := controller.Render(domain.Size{Cols: 80, Rows: 24})
155+ require.NotEmpty(t, firstRender)
156+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
157+ require.Equal(t, alphaKey, mustCursorKey(t, controller))
158+
159+ // A newer publication is folded synchronously: the very next render already
160+ // reflects it, with no debounce or intervening frame.
161+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "beta"))
162+ require.Equal(t, pickerRowKeyByLabel(t, controller, "beta"), mustCursorKey(t, controller))
163+ require.NotEmpty(t, controller.Render(domain.Size{Cols: 80, Rows: 24}))
164+}
165+
166+func TestPickerControllerSearchSurvivesHostUpdate(t *testing.T) {
167+ controller, clock := pickerTestController(t)
168+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
169+
170+ spy := newPickerInputSpy(controller)
171+ reader := newPickerChunkReader()
172+ t.Cleanup(reader.close)
173+ _ = startTerminalInputLifetime(reader, spy)
174+
175+ reader.push([]byte("/alp"))
176+ spy.await(t)
177+ require.Equal(t, "alp", controller.modelQuery(t))
178+ require.True(t, controller.modelSearchActive(t))
179+
180+ // A host update while the query is typed keeps the editor and the matched
181+ // cursor; it never resets the local search.
182+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
183+ require.Equal(t, "alp", controller.modelQuery(t))
184+ require.True(t, controller.modelSearchActive(t))
185+ require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), mustCursorKey(t, controller))
186+}
187+
188+// TestPickerControllerInputNeverReachesPTY feeds every input shape a picker can
189+// receive and proves the only effects are local: each batch is consumed, the
190+// terminal receives no bytes, and the decisions are presentation facts, never a
191+// session write.
192+func TestPickerControllerInputNeverReachesPTY(t *testing.T) {
193+ controller, clock := pickerTestController(t)
194+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
195+
196+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
197+ betaKey := pickerRowKeyByLabel(t, controller, "beta")
198+
199+ spy := newPickerInputSpy(controller)
200+ reader := newPickerChunkReader()
201+ t.Cleanup(reader.close)
202+ terminal := &pickerRecordingTerminal{in: reader}
203+ _ = startTerminalInputLifetime(terminal.In(), spy)
204+
205+ chunks := [][]byte{
206+ []byte("\x1b[B"), // Down -> beta
207+ []byte("k"), // up -> alpha
208+ []byte("j"), // down -> beta
209+ []byte("\x1b[A"), // Up -> alpha
210+ []byte("\x1b[<0;10;5M"), // mouse report: consumed and dropped
211+ []byte("\x1b[200~rm -rf /\x1b[201~"), // bracketed paste: content dropped
212+ []byte("\x00\x01\x7f"), // control bytes: consumed and dropped
213+ []byte("\x1b"), // lone ESC withheld for disambiguation
214+ }
215+ for _, chunk := range chunks {
216+ reader.push(chunk)
217+ spy.await(t)
218+ }
219+ require.Equal(t, alphaKey, mustCursorKey(t, controller), "arrow keys moved the local cursor")
220+ require.False(t, controller.modelSearchActive(t), "mouse, paste, and control bytes never opened search")
221+ require.Equal(t, betaKey, pickerRowKeyByLabel(t, controller, "beta"))
222+
223+ // A withheld lone ESC resolves through the bounded window as one Escape.
224+ controller.FlushPending()
225+ op, _ := controller.TakeOp()
226+ require.True(t, op.close, "Escape retires the picker locally")
227+
228+ require.Empty(t, terminal.out.Bytes(), "the picker wrote nothing to the terminal")
229+}
230+
231+func TestPickerControllerInputRouting(t *testing.T) {
232+ tests := []struct {
233+ name string
234+ chunk []byte
235+ flush bool
236+ wantCursor string
237+ wantNoSelection bool
238+ wantSearch bool
239+ wantQuery string
240+ wantOp pickerOp
241+ }{
242+ {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
243+ {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true},
244+ {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
245+ {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
246+ {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
247+ {name: "kill", chunk: []byte("x"), wantCursor: "alpha", wantOp: pickerOp{kill: true}},
248+ {name: "escape exits search first", chunk: []byte("/al\x1b"), flush: true, wantCursor: "alpha"},
249+ {name: "escape closes", chunk: []byte("\x1b"), flush: true, wantCursor: "alpha", wantOp: pickerOp{close: true}},
250+ {name: "f1 is consumed", chunk: []byte("\x1b[11~"), wantCursor: "alpha"},
251+ }
252+
253+ for _, tt := range tests {
254+ t.Run(tt.name, func(t *testing.T) {
255+ controller, clock := pickerTestController(t)
256+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
257+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
258+ spy := newPickerInputSpy(controller)
259+ reader := newPickerChunkReader()
260+ t.Cleanup(reader.close)
261+ _ = startTerminalInputLifetime(reader, spy)
262+
263+ reader.push(tt.chunk)
264+ spy.await(t)
265+ if tt.flush {
266+ controller.FlushPending()
267+ }
268+ if tt.wantOp != (pickerOp{}) {
269+ op, _ := controller.TakeOp()
270+ require.Equal(t, tt.wantOp, op)
271+ }
272+ require.Equal(t, tt.wantSearch, controller.modelSearchActive(t))
273+ if tt.wantQuery != "" {
274+ require.Equal(t, tt.wantQuery, controller.modelQuery(t))
275+ }
276+ if tt.wantNoSelection {
277+ _, ok := controller.CursorKey()
278+ require.False(t, ok, "the host row is a cursor destination but not a selection")
279+ return
280+ }
281+ want := alphaKey
282+ if tt.wantCursor == "beta" {
283+ want = pickerRowKeyByLabel(t, controller, "beta")
284+ }
285+ require.Equal(t, want, mustCursorKey(t, controller))
286+ })
287+ }
288+}
289+
290+// TestPickerControllerNotOwningInputLeavesReadUnconsumed pins the ownership
291+// boundary: once the picker releases input, a read is not consumed, so a later
292+// attach slice keeps its ordinary pipeline.
293+func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
294+ controller, clock := pickerTestController(t)
295+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
296+ controller.SetOwnsInput(false)
297+ require.False(t, controller.ConsumeTerminalRead([]byte("j")))
298+ controller.SetOwnsInput(true)
299+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
300+}
301+
302+func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) {
303+ controller, clock := pickerTestController(t)
304+ checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305+ checking.Checking = true
306+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress")
308+
309+ unreachable := pickerTestLocalObservation(clock.Now())
310+ unreachable.Availability = domain.RemoteAvailabilityUnreachable
311+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})
312+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")
313+}
314+
315+// TestPickerControllerResolveRefusesInspectableRow: a row the picker displays
316+// for inspection (a stale host) still resolves to a typed refusal, so the user
317+// never gets a generic failure for a known cause.
318+func TestPickerControllerResolveRefusesInspectableRow(t *testing.T) {
319+ controller, clock := pickerTestController(t)
320+ stale := pickerTestLocalObservation(clock.Now().Add(-2*pickerTestFreshness), pickerTestSession("alpha", 1, catalogue_Up))
321+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}})
322+
323+ // The cursor first rests on the host status row, then reaches the stale
324+ // session row; neither admits an action.
325+ _, ok := controller.CursorKey()
326+ require.False(t, ok)
327+ controller.ConsumeTerminalRead([]byte("j"))
328+ require.False(t, controller.modelSearchActive(t))
329+
330+ _, err := controller.Resolve(pickerTestBase())
331+ require.Error(t, err)
332+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueStale), "a stale row refuses with its exact cause")
333+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}))
334+}
335+
336+// TestPickerControllerResolveUsesLatestSnapshot proves resolution revalidates
337+// against the snapshot in force at resolve time, not the one displayed.
338+func TestPickerControllerResolveUsesLatestSnapshot(t *testing.T) {
339+ controller, clock := pickerTestController(t)
340+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
341+ key := mustCursorKey(t, controller)
342+
343+ // The host disappears before the selection resolves.
344+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2})
345+
346+ _, err := controller.Resolve(pickerTestBase())
347+ require.Error(t, err)
348+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueNoSelection), "the vanished row leaves no selection")
349+ _, err = controller.Catalogue().Resolve(key, pickerTestBase())
350+ require.Error(t, err)
351+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone))
352+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "the refusal is surfaced as a bounded notice")
353+}
354+
355+func TestPickerControllerResolveReturnsExactRequest(t *testing.T) {
356+ controller, clock := pickerTestController(t)
357+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
358+
359+ request, err := controller.Resolve(pickerTestBase())
360+ require.NoError(t, err)
361+ require.NoError(t, request.Validate())
362+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
363+ require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
364+ require.Equal(t, "alpha", request.Target.SessionName)
365+}
366+
367+// TestPickerControllerConcurrentUpdatesAndInput exercises the mutex boundaries
368+// under -race: publications, input consumption, rendering, and resolution run
369+// concurrently and must not race or panic.
370+func TestPickerControllerConcurrentUpdatesAndInput(t *testing.T) {
371+ const iterations = 200
372+ controller, clock := pickerTestController(t)
373+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
374+
375+ var wait sync.WaitGroup
376+ wait.Add(3)
377+
378+ go func() {
379+ defer wait.Done()
380+ for revision := ports.BrokerRevision(2); revision < 2+iterations; revision++ {
381+ controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
382+ }
383+ }()
384+
385+ go func() {
386+ defer wait.Done()
387+ for range iterations {
388+ controller.ConsumeTerminalRead([]byte("\x1b[B\x1b[Aj"))
389+ controller.FlushPending()
390+ controller.TakeOp()
391+ }
392+ }()
393+
394+ go func() {
395+ defer wait.Done()
396+ for range iterations {
397+ _ = controller.Render(domain.Size{Cols: 80, Rows: 24})
398+ _ = controller.RenderNotice(domain.Size{Cols: 80, Rows: 24})
399+ _, _ = controller.Resolve(pickerTestBase())
400+ _, _ = controller.CursorKey()
401+ }
402+ }()
403+
404+ wait.Wait()
405+}
406+
407+// TestSupervisorPickerFoldsPublicationsAndInput proves the supervisor wiring:
408+// a broker publication reaches the picker through the established connection's
409+// subscription, and terminal input is consumed by the picker from the same
410+// single input lifetime the supervisor uses for EOF, never written to the
411+// terminal.
412+func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
413+ ctx, cancel := context.WithCancel(context.Background())
414+ defer cancel()
415+
416+ clock := newSupervisorTestClock()
417+ reader := newPickerChunkReader()
418+ t.Cleanup(reader.close)
419+ terminal := &pickerRecordingTerminal{in: reader}
420+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
421+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
422+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
423+ return service, nil
424+ })
425+ controller := newPickerController(clock, pickerTestFreshness)
426+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
427+
428+ runErr := make(chan error, 1)
429+ go func() { runErr <- sup.Run(ctx) }()
430+
431+ require.Eventually(t, func() bool {
432+ return controller.Catalogue().Epoch() == 3
433+ }, time.Second, time.Millisecond, "the supervisor folds the committed publication into the picker")
434+ require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
435+ require.True(t, controller.OwnsInput())
436+
437+ // The same input lifetime the supervisor uses for EOF delivers the read to
438+ // the picker: the cursor moves locally and nothing is written anywhere.
439+ betaKey := pickerRowKeyByLabel(t, controller, "beta")
440+ reader.push([]byte("\x1b[B"))
441+ require.Eventually(t, func() bool {
442+ key, ok := controller.CursorKey()
443+ return ok && key == betaKey
444+ }, time.Second, time.Millisecond, "the picker consumed the terminal read")
445+
446+ cancel()
447+ require.ErrorIs(t, <-runErr, context.Canceled)
448+ require.Empty(t, terminal.out.Bytes(), "the picker never wrote a session or terminal byte")
449+}
450+
451+// TestSupervisorPickerFoldsLaterPublication covers the second-and-later
452+// publication: a snapshot delivered after the connection is established must
453+// reach the picker through the subscription awaitLoss parks on. This is the
454+// core of P5.2b and the fake hub's non-blocking, never-replaced channel is what
455+// keeps it from busy-spinning.
456+func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
457+ ctx, cancel := context.WithCancel(context.Background())
458+ defer cancel()
459+
460+ clock := newSupervisorTestClock()
461+ reader := newPickerChunkReader()
462+ t.Cleanup(reader.close)
463+ terminal := &pickerRecordingTerminal{in: reader}
464+
465+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
466+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
467+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
468+ return service, nil
469+ })
470+ controller := newPickerController(clock, pickerTestFreshness)
471+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
472+
473+ runErr := make(chan error, 1)
474+ go func() { runErr <- sup.Run(ctx) }()
475+
476+ require.Eventually(t, func() bool {
477+ return controller.Catalogue().Revision() == 1
478+ }, time.Second, time.Millisecond, "the first committed publication reaches the picker")
479+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines()))
480+
481+ service.publishSnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta"))
482+ require.Eventually(t, func() bool {
483+ return controller.Catalogue().Revision() == 2
484+ }, time.Second, time.Millisecond, "a publication delivered after the connection is established reaches the picker")
485+ require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
486+
487+ cancel()
488+ require.ErrorIs(t, <-runErr, context.Canceled)
489+}
490+
491+// TestPickerControllerResolveKeyUsesCommittedRow proves commit and resolve are
492+// one action: the key captured with the commit decision resolves the committed
493+// row even when a concurrent publication moved the cursor afterwards.
494+func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) {
495+ controller, clock := pickerTestController(t)
496+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
497+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
498+
499+ // The user commits the row under the cursor.
500+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
501+ op, committedKey := controller.TakeOp()
502+ require.True(t, op.commit)
503+ require.Equal(t, alphaKey, committedKey)
504+
505+ // A concurrent publication moves the cursor to another row, and the driver
506+ // commits a row different from the one the user selected.
507+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
508+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
509+
510+ // Plain Resolve describes the row displayed now...
511+ displayed, err := controller.Resolve(pickerTestBase())
512+ require.NoError(t, err)
513+ require.Equal(t, "beta", displayed.Target.SessionName)
514+
515+ // ...while the captured key resolves exactly the committed row.
516+ committed, err := controller.ResolveKey(committedKey, pickerTestBase())
517+ require.NoError(t, err)
518+ require.Equal(t, "alpha", committed.Target.SessionName)
519+}
520+
521+// TestPickerControllerCommittedKeySurvivesConcurrentApply runs the commit path
522+// under -race against a driver that keeps republishing. The captured key must
523+// always resolve the row the user committed, never the row a publication left
524+// under the cursor.
525+func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) {
526+ controller, clock := pickerTestController(t)
527+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
528+
529+ stop := make(chan struct{})
530+ var applying sync.WaitGroup
531+ applying.Add(1)
532+ go func() {
533+ defer applying.Done()
534+ revision := ports.BrokerRevision(2)
535+ for {
536+ select {
537+ case <-stop:
538+ return
539+ default:
540+ }
541+ controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
542+ revision++
543+ }
544+ }()
545+
546+ for range 200 {
547+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
548+ op, key := controller.TakeOp()
549+ if !op.commit || key == "" {
550+ continue
551+ }
552+ request, err := controller.ResolveKey(key, pickerTestBase())
553+ if err != nil {
554+ continue
555+ }
556+ require.Equal(t, "alpha", request.Target.SessionName, "the committed key must never resolve another row")
557+ }
558+ close(stop)
559+ applying.Wait()
560+}
561+
562+// TestPickerControllerApplySnapshotRejectsInvalidSnapshot proves the controller
563+// keeps the previous projection and surfaces a bounded notice when a publication
564+// fails the broker snapshot contract.
565+func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
566+ controller, clock := pickerTestController(t)
567+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
568+ before := controller.Catalogue().Lines()
569+
570+ invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta")
571+ invalid.Daemons[0].Availability = 0
572+ controller.ApplySnapshot(invalid)
573+
574+ require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")
575+ require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
576+
577+ controller.mu.Lock()
578+ active := controller.notices.Active(clock.Now())
579+ controller.mu.Unlock()
580+ require.Len(t, active, 1)
581+ require.Equal(t, "broker catalogue update rejected", active[0].Message)
582+ require.NotContains(t, active[0].Message, "user@")
583+}
584+
585+// TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
586+// availability-first classification at the controller boundary: an unobserved
587+// daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing
588+// notice, never a version_mismatch.
589+func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
590+ controller, clock := pickerTestController(t)
591+ unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
592+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
593+
594+ controller.mu.Lock()
595+ active := controller.notices.Active(clock.Now())
596+ controller.mu.Unlock()
597+ require.Len(t, active, 1)
598+ require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message)
599+ require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch)
600+}
601+
602+// TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is
603+// the newest by ShownAt (tie-broken by ID), independent of the toast manager's
604+// map-iteration order.
605+func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) {
606+ controller, _ := pickerTestController(t)
607+ size := domain.Size{Cols: 80, Rows: 24}
608+
609+ controller.mu.Lock()
610+ controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"})
611+ controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"})
612+ controller.mu.Unlock()
613+ require.Contains(t, string(controller.RenderNotice(size)), "newer-message")
614+
615+ // Equal ShownAt: the lexicographically greatest ID wins, deterministically.
616+ controller.mu.Lock()
617+ controller.notices.Clear()
618+ controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"})
619+ controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"})
620+ controller.mu.Unlock()
621+ for range 10 {
622+ require.Contains(t, string(controller.RenderNotice(size)), "from-bbb")
623+ }
624+}
@@ -0,0 +1,624 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol/catalogue"
16+ "github.com/bnema/vev/internal/usecase/ui"
17+)
18+
19+// Client-owned picker controller tests (Plan 001 P5.2b): rendering, local
20+// search/cursor behaviour across live updates, and the input-ownership proof
21+// that picker input never reaches a session.
22+
23+// pickerChunkReader yields pre-pushed chunks to the supervisor's single input
24+// lifetime. push blocks until the lifetime reads the chunk, so a test can
25+// order a read deterministically without a sleep.
26+type pickerChunkReader struct {
27+ chunks chan []byte
28+}
29+
30+func newPickerChunkReader() *pickerChunkReader {
31+ return &pickerChunkReader{chunks: make(chan []byte)}
32+}
33+
34+func (r *pickerChunkReader) push(data []byte) {
35+ r.chunks <- append([]byte(nil), data...)
36+}
37+
38+func (r *pickerChunkReader) close() { close(r.chunks) }
39+
40+func (r *pickerChunkReader) Read(buffer []byte) (int, error) {
41+ chunk, ok := <-r.chunks
42+ if !ok {
43+ return 0, io.EOF
44+ }
45+ return copy(buffer, chunk), nil
46+}
47+
48+// pickerInputSpy forwards each read to the real consumer and signals that it
49+// was delivered, so a test can wait exactly for consumption.
50+type pickerInputSpy struct {
51+ inner pickerInputConsumer
52+ done chan struct{}
53+}
54+
55+func newPickerInputSpy(inner pickerInputConsumer) *pickerInputSpy {
56+ return &pickerInputSpy{inner: inner, done: make(chan struct{}, 1)}
57+}
58+
59+func (s *pickerInputSpy) ConsumeTerminalRead(data []byte) bool {
60+ consumed := s.inner.ConsumeTerminalRead(data)
61+ select {
62+ case s.done <- struct{}{}:
63+ default:
64+ }
65+ return consumed
66+}
67+
68+func (s *pickerInputSpy) await(t *testing.T) {
69+ t.Helper()
70+ select {
71+ case <-s.done:
72+ case <-time.After(5 * time.Second):
73+ t.Fatal("terminal input was not consumed")
74+ }
75+}
76+
77+// pickerRecordingTerminal records every byte written to the terminal output,
78+// which is where a leaked picker key would surface if it reached a session.
79+type pickerRecordingTerminal struct {
80+ in io.Reader
81+ out bytes.Buffer
82+ mu sync.Mutex
83+ enters int
84+}
85+
86+func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) {
87+ t.mu.Lock()
88+ t.enters++
89+ t.mu.Unlock()
90+ return func() error { return nil }, nil
91+}
92+
93+func (t *pickerRecordingTerminal) Geometry() (domain.Geometry, error) {
94+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
95+}
96+
97+func (t *pickerRecordingTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
98+
99+func (t *pickerRecordingTerminal) In() io.Reader { return t.in }
100+
101+func (t *pickerRecordingTerminal) Out() io.Writer { return &t.out }
102+
103+func (t *pickerRecordingTerminal) Flush() error { return nil }
104+
105+func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, now time.Time, sessions ...string) ports.BrokerSnapshot {
106+ catalogSessions := make([]catalogue.RemoteCatalogSession, 0, len(sessions))
107+ for i, name := range sessions {
108+ catalogSessions = append(catalogSessions, pickerTestSession(name, byte(i+1), catalogue_Up))
109+ }
110+ return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{
111+ pickerTestLocalObservation(now, catalogSessions...),
112+ }}
113+}
114+
115+func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
116+ t.Helper()
117+ clock := newSupervisorTestClock()
118+ return newPickerController(clock, pickerTestFreshness), clock
119+}
120+
121+func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
122+ t.Helper()
123+ line, ok := pickerLineByLabel(controller.Catalogue().Lines(), label)
124+ require.True(t, ok, "row %q must be projected", label)
125+ return line.Key
126+}
127+
128+func mustCursorKey(t *testing.T, controller *pickerController) string {
129+ t.Helper()
130+ key, ok := controller.CursorKey()
131+ require.True(t, ok, "the picker must have a selected row")
132+ return key
133+}
134+
135+func (p *pickerController) modelQuery(t *testing.T) string {
136+ t.Helper()
137+ p.mu.Lock()
138+ defer p.mu.Unlock()
139+ require.NotNil(t, p.loop)
140+ return p.loop.model.Query()
141+}
142+
143+func (p *pickerController) modelSearchActive(t *testing.T) bool {
144+ t.Helper()
145+ p.mu.Lock()
146+ defer p.mu.Unlock()
147+ require.NotNil(t, p.loop)
148+ return p.loop.model.SearchActive()
149+}
150+
151+func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) {
152+ controller, clock := pickerTestController(t)
153+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
154+ firstRender := controller.Render(domain.Size{Cols: 80, Rows: 24})
155+ require.NotEmpty(t, firstRender)
156+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
157+ require.Equal(t, alphaKey, mustCursorKey(t, controller))
158+
159+ // A newer publication is folded synchronously: the very next render already
160+ // reflects it, with no debounce or intervening frame.
161+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "beta"))
162+ require.Equal(t, pickerRowKeyByLabel(t, controller, "beta"), mustCursorKey(t, controller))
163+ require.NotEmpty(t, controller.Render(domain.Size{Cols: 80, Rows: 24}))
164+}
165+
166+func TestPickerControllerSearchSurvivesHostUpdate(t *testing.T) {
167+ controller, clock := pickerTestController(t)
168+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
169+
170+ spy := newPickerInputSpy(controller)
171+ reader := newPickerChunkReader()
172+ t.Cleanup(reader.close)
173+ _ = startTerminalInputLifetime(reader, spy)
174+
175+ reader.push([]byte("/alp"))
176+ spy.await(t)
177+ require.Equal(t, "alp", controller.modelQuery(t))
178+ require.True(t, controller.modelSearchActive(t))
179+
180+ // A host update while the query is typed keeps the editor and the matched
181+ // cursor; it never resets the local search.
182+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
183+ require.Equal(t, "alp", controller.modelQuery(t))
184+ require.True(t, controller.modelSearchActive(t))
185+ require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), mustCursorKey(t, controller))
186+}
187+
188+// TestPickerControllerInputNeverReachesPTY feeds every input shape a picker can
189+// receive and proves the only effects are local: each batch is consumed, the
190+// terminal receives no bytes, and the decisions are presentation facts, never a
191+// session write.
192+func TestPickerControllerInputNeverReachesPTY(t *testing.T) {
193+ controller, clock := pickerTestController(t)
194+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
195+
196+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
197+ betaKey := pickerRowKeyByLabel(t, controller, "beta")
198+
199+ spy := newPickerInputSpy(controller)
200+ reader := newPickerChunkReader()
201+ t.Cleanup(reader.close)
202+ terminal := &pickerRecordingTerminal{in: reader}
203+ _ = startTerminalInputLifetime(terminal.In(), spy)
204+
205+ chunks := [][]byte{
206+ []byte("\x1b[B"), // Down -> beta
207+ []byte("k"), // up -> alpha
208+ []byte("j"), // down -> beta
209+ []byte("\x1b[A"), // Up -> alpha
210+ []byte("\x1b[<0;10;5M"), // mouse report: consumed and dropped
211+ []byte("\x1b[200~rm -rf /\x1b[201~"), // bracketed paste: content dropped
212+ []byte("\x00\x01\x7f"), // control bytes: consumed and dropped
213+ []byte("\x1b"), // lone ESC withheld for disambiguation
214+ }
215+ for _, chunk := range chunks {
216+ reader.push(chunk)
217+ spy.await(t)
218+ }
219+ require.Equal(t, alphaKey, mustCursorKey(t, controller), "arrow keys moved the local cursor")
220+ require.False(t, controller.modelSearchActive(t), "mouse, paste, and control bytes never opened search")
221+ require.Equal(t, betaKey, pickerRowKeyByLabel(t, controller, "beta"))
222+
223+ // A withheld lone ESC resolves through the bounded window as one Escape.
224+ controller.FlushPending()
225+ op, _ := controller.TakeOp()
226+ require.True(t, op.close, "Escape retires the picker locally")
227+
228+ require.Empty(t, terminal.out.Bytes(), "the picker wrote nothing to the terminal")
229+}
230+
231+func TestPickerControllerInputRouting(t *testing.T) {
232+ tests := []struct {
233+ name string
234+ chunk []byte
235+ flush bool
236+ wantCursor string
237+ wantNoSelection bool
238+ wantSearch bool
239+ wantQuery string
240+ wantOp pickerOp
241+ }{
242+ {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"},
243+ {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true},
244+ {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true},
245+ {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"},
246+ {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}},
247+ {name: "kill", chunk: []byte("x"), wantCursor: "alpha", wantOp: pickerOp{kill: true}},
248+ {name: "escape exits search first", chunk: []byte("/al\x1b"), flush: true, wantCursor: "alpha"},
249+ {name: "escape closes", chunk: []byte("\x1b"), flush: true, wantCursor: "alpha", wantOp: pickerOp{close: true}},
250+ {name: "f1 is consumed", chunk: []byte("\x1b[11~"), wantCursor: "alpha"},
251+ }
252+
253+ for _, tt := range tests {
254+ t.Run(tt.name, func(t *testing.T) {
255+ controller, clock := pickerTestController(t)
256+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
257+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
258+ spy := newPickerInputSpy(controller)
259+ reader := newPickerChunkReader()
260+ t.Cleanup(reader.close)
261+ _ = startTerminalInputLifetime(reader, spy)
262+
263+ reader.push(tt.chunk)
264+ spy.await(t)
265+ if tt.flush {
266+ controller.FlushPending()
267+ }
268+ if tt.wantOp != (pickerOp{}) {
269+ op, _ := controller.TakeOp()
270+ require.Equal(t, tt.wantOp, op)
271+ }
272+ require.Equal(t, tt.wantSearch, controller.modelSearchActive(t))
273+ if tt.wantQuery != "" {
274+ require.Equal(t, tt.wantQuery, controller.modelQuery(t))
275+ }
276+ if tt.wantNoSelection {
277+ _, ok := controller.CursorKey()
278+ require.False(t, ok, "the host row is a cursor destination but not a selection")
279+ return
280+ }
281+ want := alphaKey
282+ if tt.wantCursor == "beta" {
283+ want = pickerRowKeyByLabel(t, controller, "beta")
284+ }
285+ require.Equal(t, want, mustCursorKey(t, controller))
286+ })
287+ }
288+}
289+
290+// TestPickerControllerNotOwningInputLeavesReadUnconsumed pins the ownership
291+// boundary: once the picker releases input, a read is not consumed, so a later
292+// attach slice keeps its ordinary pipeline.
293+func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) {
294+ controller, clock := pickerTestController(t)
295+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
296+ controller.SetOwnsInput(false)
297+ require.False(t, controller.ConsumeTerminalRead([]byte("j")))
298+ controller.SetOwnsInput(true)
299+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
300+}
301+
302+func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) {
303+ controller, clock := pickerTestController(t)
304+ checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
305+ checking.Checking = true
306+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}})
307+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress")
308+
309+ unreachable := pickerTestLocalObservation(clock.Now())
310+ unreachable.Availability = domain.RemoteAvailabilityUnreachable
311+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}})
312+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure")
313+}
314+
315+// TestPickerControllerResolveRefusesInspectableRow: a row the picker displays
316+// for inspection (a stale host) still resolves to a typed refusal, so the user
317+// never gets a generic failure for a known cause.
318+func TestPickerControllerResolveRefusesInspectableRow(t *testing.T) {
319+ controller, clock := pickerTestController(t)
320+ stale := pickerTestLocalObservation(clock.Now().Add(-2*pickerTestFreshness), pickerTestSession("alpha", 1, catalogue_Up))
321+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}})
322+
323+ // The cursor first rests on the host status row, then reaches the stale
324+ // session row; neither admits an action.
325+ _, ok := controller.CursorKey()
326+ require.False(t, ok)
327+ controller.ConsumeTerminalRead([]byte("j"))
328+ require.False(t, controller.modelSearchActive(t))
329+
330+ _, err := controller.Resolve(pickerTestBase())
331+ require.Error(t, err)
332+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueStale), "a stale row refuses with its exact cause")
333+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}))
334+}
335+
336+// TestPickerControllerResolveUsesLatestSnapshot proves resolution revalidates
337+// against the snapshot in force at resolve time, not the one displayed.
338+func TestPickerControllerResolveUsesLatestSnapshot(t *testing.T) {
339+ controller, clock := pickerTestController(t)
340+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
341+ key := mustCursorKey(t, controller)
342+
343+ // The host disappears before the selection resolves.
344+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2})
345+
346+ _, err := controller.Resolve(pickerTestBase())
347+ require.Error(t, err)
348+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueNoSelection), "the vanished row leaves no selection")
349+ _, err = controller.Catalogue().Resolve(key, pickerTestBase())
350+ require.Error(t, err)
351+ require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone))
352+ require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "the refusal is surfaced as a bounded notice")
353+}
354+
355+func TestPickerControllerResolveReturnsExactRequest(t *testing.T) {
356+ controller, clock := pickerTestController(t)
357+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
358+
359+ request, err := controller.Resolve(pickerTestBase())
360+ require.NoError(t, err)
361+ require.NoError(t, request.Validate())
362+ require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
363+ require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
364+ require.Equal(t, "alpha", request.Target.SessionName)
365+}
366+
367+// TestPickerControllerConcurrentUpdatesAndInput exercises the mutex boundaries
368+// under -race: publications, input consumption, rendering, and resolution run
369+// concurrently and must not race or panic.
370+func TestPickerControllerConcurrentUpdatesAndInput(t *testing.T) {
371+ const iterations = 200
372+ controller, clock := pickerTestController(t)
373+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
374+
375+ var wait sync.WaitGroup
376+ wait.Add(3)
377+
378+ go func() {
379+ defer wait.Done()
380+ for revision := ports.BrokerRevision(2); revision < 2+iterations; revision++ {
381+ controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
382+ }
383+ }()
384+
385+ go func() {
386+ defer wait.Done()
387+ for range iterations {
388+ controller.ConsumeTerminalRead([]byte("\x1b[B\x1b[Aj"))
389+ controller.FlushPending()
390+ controller.TakeOp()
391+ }
392+ }()
393+
394+ go func() {
395+ defer wait.Done()
396+ for range iterations {
397+ _ = controller.Render(domain.Size{Cols: 80, Rows: 24})
398+ _ = controller.RenderNotice(domain.Size{Cols: 80, Rows: 24})
399+ _, _ = controller.Resolve(pickerTestBase())
400+ _, _ = controller.CursorKey()
401+ }
402+ }()
403+
404+ wait.Wait()
405+}
406+
407+// TestSupervisorPickerFoldsPublicationsAndInput proves the supervisor wiring:
408+// a broker publication reaches the picker through the established connection's
409+// subscription, and terminal input is consumed by the picker from the same
410+// single input lifetime the supervisor uses for EOF, never written to the
411+// terminal.
412+func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
413+ ctx, cancel := context.WithCancel(context.Background())
414+ defer cancel()
415+
416+ clock := newSupervisorTestClock()
417+ reader := newPickerChunkReader()
418+ t.Cleanup(reader.close)
419+ terminal := &pickerRecordingTerminal{in: reader}
420+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
421+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
422+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
423+ return service, nil
424+ })
425+ controller := newPickerController(clock, pickerTestFreshness)
426+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
427+
428+ runErr := make(chan error, 1)
429+ go func() { runErr <- sup.Run(ctx) }()
430+
431+ require.Eventually(t, func() bool {
432+ return controller.Catalogue().Epoch() == 3
433+ }, time.Second, time.Millisecond, "the supervisor folds the committed publication into the picker")
434+ require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
435+ require.True(t, controller.OwnsInput())
436+
437+ // The same input lifetime the supervisor uses for EOF delivers the read to
438+ // the picker: the cursor moves locally and nothing is written anywhere.
439+ betaKey := pickerRowKeyByLabel(t, controller, "beta")
440+ reader.push([]byte("\x1b[B"))
441+ require.Eventually(t, func() bool {
442+ key, ok := controller.CursorKey()
443+ return ok && key == betaKey
444+ }, time.Second, time.Millisecond, "the picker consumed the terminal read")
445+
446+ cancel()
447+ require.ErrorIs(t, <-runErr, context.Canceled)
448+ require.Empty(t, terminal.out.Bytes(), "the picker never wrote a session or terminal byte")
449+}
450+
451+// TestSupervisorPickerFoldsLaterPublication covers the second-and-later
452+// publication: a snapshot delivered after the connection is established must
453+// reach the picker through the subscription awaitLoss parks on. This is the
454+// core of P5.2b and the fake hub's non-blocking, never-replaced channel is what
455+// keeps it from busy-spinning.
456+func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
457+ ctx, cancel := context.WithCancel(context.Background())
458+ defer cancel()
459+
460+ clock := newSupervisorTestClock()
461+ reader := newPickerChunkReader()
462+ t.Cleanup(reader.close)
463+ terminal := &pickerRecordingTerminal{in: reader}
464+
465+ service := newSupervisorTestService(ports.BrokerConnectionID{1})
466+ connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) {
467+ service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
468+ return service, nil
469+ })
470+ controller := newPickerController(clock, pickerTestFreshness)
471+ sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
472+
473+ runErr := make(chan error, 1)
474+ go func() { runErr <- sup.Run(ctx) }()
475+
476+ require.Eventually(t, func() bool {
477+ return controller.Catalogue().Revision() == 1
478+ }, time.Second, time.Millisecond, "the first committed publication reaches the picker")
479+ require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines()))
480+
481+ service.publishSnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta"))
482+ require.Eventually(t, func() bool {
483+ return controller.Catalogue().Revision() == 2
484+ }, time.Second, time.Millisecond, "a publication delivered after the connection is established reaches the picker")
485+ require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines()))
486+
487+ cancel()
488+ require.ErrorIs(t, <-runErr, context.Canceled)
489+}
490+
491+// TestPickerControllerResolveKeyUsesCommittedRow proves commit and resolve are
492+// one action: the key captured with the commit decision resolves the committed
493+// row even when a concurrent publication moved the cursor afterwards.
494+func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) {
495+ controller, clock := pickerTestController(t)
496+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
497+ alphaKey := pickerRowKeyByLabel(t, controller, "alpha")
498+
499+ // The user commits the row under the cursor.
500+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
501+ op, committedKey := controller.TakeOp()
502+ require.True(t, op.commit)
503+ require.Equal(t, alphaKey, committedKey)
504+
505+ // A concurrent publication moves the cursor to another row, and the driver
506+ // commits a row different from the one the user selected.
507+ controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma"))
508+ require.True(t, controller.ConsumeTerminalRead([]byte("j")))
509+
510+ // Plain Resolve describes the row displayed now...
511+ displayed, err := controller.Resolve(pickerTestBase())
512+ require.NoError(t, err)
513+ require.Equal(t, "beta", displayed.Target.SessionName)
514+
515+ // ...while the captured key resolves exactly the committed row.
516+ committed, err := controller.ResolveKey(committedKey, pickerTestBase())
517+ require.NoError(t, err)
518+ require.Equal(t, "alpha", committed.Target.SessionName)
519+}
520+
521+// TestPickerControllerCommittedKeySurvivesConcurrentApply runs the commit path
522+// under -race against a driver that keeps republishing. The captured key must
523+// always resolve the row the user committed, never the row a publication left
524+// under the cursor.
525+func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) {
526+ controller, clock := pickerTestController(t)
527+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
528+
529+ stop := make(chan struct{})
530+ var applying sync.WaitGroup
531+ applying.Add(1)
532+ go func() {
533+ defer applying.Done()
534+ revision := ports.BrokerRevision(2)
535+ for {
536+ select {
537+ case <-stop:
538+ return
539+ default:
540+ }
541+ controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta"))
542+ revision++
543+ }
544+ }()
545+
546+ for range 200 {
547+ require.True(t, controller.ConsumeTerminalRead([]byte("\r")))
548+ op, key := controller.TakeOp()
549+ if !op.commit || key == "" {
550+ continue
551+ }
552+ request, err := controller.ResolveKey(key, pickerTestBase())
553+ if err != nil {
554+ continue
555+ }
556+ require.Equal(t, "alpha", request.Target.SessionName, "the committed key must never resolve another row")
557+ }
558+ close(stop)
559+ applying.Wait()
560+}
561+
562+// TestPickerControllerApplySnapshotRejectsInvalidSnapshot proves the controller
563+// keeps the previous projection and surfaces a bounded notice when a publication
564+// fails the broker snapshot contract.
565+func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) {
566+ controller, clock := pickerTestController(t)
567+ controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
568+ before := controller.Catalogue().Lines()
569+
570+ invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta")
571+ invalid.Daemons[0].Availability = 0
572+ controller.ApplySnapshot(invalid)
573+
574+ require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept")
575+ require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision())
576+
577+ controller.mu.Lock()
578+ active := controller.notices.Active(clock.Now())
579+ controller.mu.Unlock()
580+ require.Len(t, active, 1)
581+ require.Equal(t, "broker catalogue update rejected", active[0].Message)
582+ require.NotContains(t, active[0].Message, "user@")
583+}
584+
585+// TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the
586+// availability-first classification at the controller boundary: an unobserved
587+// daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing
588+// notice, never a version_mismatch.
589+func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) {
590+ controller, clock := pickerTestController(t)
591+ unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up))
592+ controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})
593+
594+ controller.mu.Lock()
595+ active := controller.notices.Active(clock.Now())
596+ controller.mu.Unlock()
597+ require.Len(t, active, 1)
598+ require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message)
599+ require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch)
600+}
601+
602+// TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is
603+// the newest by ShownAt (tie-broken by ID), independent of the toast manager's
604+// map-iteration order.
605+func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) {
606+ controller, _ := pickerTestController(t)
607+ size := domain.Size{Cols: 80, Rows: 24}
608+
609+ controller.mu.Lock()
610+ controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"})
611+ controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"})
612+ controller.mu.Unlock()
613+ require.Contains(t, string(controller.RenderNotice(size)), "newer-message")
614+
615+ // Equal ShownAt: the lexicographically greatest ID wins, deterministically.
616+ controller.mu.Lock()
617+ controller.notices.Clear()
618+ controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"})
619+ controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"})
620+ controller.mu.Unlock()
621+ for range 10 {
622+ require.Contains(t, string(controller.RenderNotice(size)), "from-bbb")
623+ }
624+}
M internal/usecase/client/supervisor.go +51 -17
@@ -216,6 +216,12 @@ type SupervisorConfig struct {
216216 // randomness seam so tests can pin the retry cadence exactly. Optional; the
217217 // default is a process-local random source.
218218 Jitter func() float64
219+ // Picker is the client-owned picker (Plan 001 P5.2b). When set, the
220+ // supervisor folds every broker publication into it and hands it every
221+ // terminal read from the same single input lifetime used for EOF detection,
222+ // so the picker never starts a second reader. When nil the supervisor drops
223+ // terminal input exactly as P5.1a does. Optional.
224+ Picker pickerHost
219225 }
220226
221227 // Supervisor owns one autonomous client process: raw mode, one terminal input
@@ -243,6 +249,11 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
243249 if cfg.Render == nil {
244250 cfg.Render = func(State) {}
245251 }
252+ if supervisorNil(cfg.Picker) {
253+ // Normalize a typed-nil picker so Run's nil check and the input
254+ // lifetime's consumer check are both safe.
255+ cfg.Picker = nil
256+ }
246257 if cfg.Jitter == nil {
247258 cfg.Jitter = rand.Float64
248259 }
@@ -279,7 +290,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
279290 }
280291 }()
281292
282- input := startTerminalInputLifetime(s.cfg.Terminal.In())
293+ input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)
283294
284295 // The active connection is owned across loop iterations. retire is
285296 // idempotent so every exit path closes exactly the connection it adopted.
@@ -356,9 +367,15 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
356367 }
357368
358369 service, sub = result.service, result.sub
370+ if s.cfg.Picker != nil {
371+ // Render the first committed publication immediately, before waiting
372+ // for the next one; the picker never shows a stale empty catalogue
373+ // while an established connection already has state.
374+ s.cfg.Picker.ApplySnapshot(service.Snapshot())
375+ }
359376 s.transition(supervisorEvent{kind: supervisorReady})
360377
361- lossErr, terminated, termErr := s.awaitLoss(ctx, input, service)
378+ lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub)
362379 retire()
363380 if terminated {
364381 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
@@ -511,14 +528,22 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
511528 // awaitLoss waits for an established connection to be lost, or for the run to
512529 // be terminated first. A loss is reported with the service's stable terminal
513530 // cause.
514-func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) {
515- select {
516- case <-service.Done():
517- return service.Err(), false, nil
518- case <-ctx.Done():
519- return nil, true, ctx.Err()
520- case err := <-input.EOF():
521- return nil, true, terminalReadCause(err)
531+func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) {
532+ var changed <-chan struct{}
533+ if s.cfg.Picker != nil && !supervisorNil(sub) {
534+ changed = sub.Changed()
535+ }
536+ for {
537+ select {
538+ case <-service.Done():
539+ return service.Err(), false, nil
540+ case <-changed:
541+ s.cfg.Picker.ApplySnapshot(service.Snapshot())
542+ case <-ctx.Done():
543+ return nil, true, ctx.Err()
544+ case err := <-input.EOF():
545+ return nil, true, terminalReadCause(err)
546+ }
522547 }
523548 }
524549
@@ -671,17 +696,21 @@ func (s *Supervisor) transition(event supervisorEvent) {
671696 // terminalInputLifetime owns the single read of the controlling terminal. It is
672697 // started once and never replaced: a bare io.Reader cannot be interrupted, so
673698 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the
674-// attach client's input pump. It never closes caller-owned input, and P5.1a
675-// discards read bytes because picker interaction is a later slice.
699+// attach client's input pump. It never closes caller-owned input. When a picker
700+// consumer is supplied (Plan 001 P5.2b) each read is handed to it from this one
701+// reader, so the picker never starts a second reader; without a consumer the
702+// bytes are discarded exactly as P5.1a does.
676703 type terminalInputLifetime struct {
677- eof chan error
678- once sync.Once
704+ eof chan error
705+ once sync.Once
706+ consumer pickerInputConsumer
679707 }
680708
681709 // startTerminalInputLifetime starts the single terminal read. A nil reader is
682-// treated as an immediate orderly EOF.
683-func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime {
684- lifetime := &terminalInputLifetime{eof: make(chan error, 1)}
710+// treated as an immediate orderly EOF. A nil consumer discards read bytes; a
711+// non-nil consumer owns them for the picker presentation.
712+func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime {
713+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer}
685714 if supervisorNil(in) {
686715 lifetime.finish(io.EOF)
687716 return lifetime
@@ -700,6 +729,11 @@ func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime {
700729 // mistaken for EOF.
701730 continue
702731 }
732+ if lifetime.consumer != nil {
733+ // The consumer may retain a partial escape or UTF-8 prefix, so it
734+ // gets its own copy rather than the reused read buffer.
735+ lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...))
736+ }
703737 }
704738 }()
705739 return lifetime
@@ -216,6 +216,12 @@ type SupervisorConfig struct {
216 // randomness seam so tests can pin the retry cadence exactly. Optional; the216 // randomness seam so tests can pin the retry cadence exactly. Optional; the
217 // default is a process-local random source.217 // default is a process-local random source.
218 Jitter func() float64218 Jitter func() float64
219+ // Picker is the client-owned picker (Plan 001 P5.2b). When set, the
220+ // supervisor folds every broker publication into it and hands it every
221+ // terminal read from the same single input lifetime used for EOF detection,
222+ // so the picker never starts a second reader. When nil the supervisor drops
223+ // terminal input exactly as P5.1a does. Optional.
224+ Picker pickerHost
219 }225 }
220 226
221 // Supervisor owns one autonomous client process: raw mode, one terminal input227 // Supervisor owns one autonomous client process: raw mode, one terminal input
@@ -243,6 +249,11 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) {
243 if cfg.Render == nil {249 if cfg.Render == nil {
244 cfg.Render = func(State) {}250 cfg.Render = func(State) {}
245 }251 }
252+ if supervisorNil(cfg.Picker) {
253+ // Normalize a typed-nil picker so Run's nil check and the input
254+ // lifetime's consumer check are both safe.
255+ cfg.Picker = nil
256+ }
246 if cfg.Jitter == nil {257 if cfg.Jitter == nil {
247 cfg.Jitter = rand.Float64258 cfg.Jitter = rand.Float64
248 }259 }
@@ -279,7 +290,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
279 }290 }
280 }()291 }()
281 292
282- input := startTerminalInputLifetime(s.cfg.Terminal.In())293+ input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker)
283 294
284 // The active connection is owned across loop iterations. retire is295 // The active connection is owned across loop iterations. retire is
285 // idempotent so every exit path closes exactly the connection it adopted.296 // idempotent so every exit path closes exactly the connection it adopted.
@@ -356,9 +367,15 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) {
356 }367 }
357 368
358 service, sub = result.service, result.sub369 service, sub = result.service, result.sub
370+ if s.cfg.Picker != nil {
371+ // Render the first committed publication immediately, before waiting
372+ // for the next one; the picker never shows a stale empty catalogue
373+ // while an established connection already has state.
374+ s.cfg.Picker.ApplySnapshot(service.Snapshot())
375+ }
359 s.transition(supervisorEvent{kind: supervisorReady})376 s.transition(supervisorEvent{kind: supervisorReady})
360 377
361- lossErr, terminated, termErr := s.awaitLoss(ctx, input, service)378+ lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub)
362 retire()379 retire()
363 if terminated {380 if terminated {
364 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})381 s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr})
@@ -511,14 +528,22 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet
511 // awaitLoss waits for an established connection to be lost, or for the run to528 // awaitLoss waits for an established connection to be lost, or for the run to
512 // be terminated first. A loss is reported with the service's stable terminal529 // be terminated first. A loss is reported with the service's stable terminal
513 // cause.530 // cause.
514-func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) {531+func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) {
515- select {532+ var changed <-chan struct{}
516- case <-service.Done():533+ if s.cfg.Picker != nil && !supervisorNil(sub) {
517- return service.Err(), false, nil534+ changed = sub.Changed()
518- case <-ctx.Done():535+ }
519- return nil, true, ctx.Err()536+ for {
520- case err := <-input.EOF():537+ select {
521- return nil, true, terminalReadCause(err)538+ case <-service.Done():
539+ return service.Err(), false, nil
540+ case <-changed:
541+ s.cfg.Picker.ApplySnapshot(service.Snapshot())
542+ case <-ctx.Done():
543+ return nil, true, ctx.Err()
544+ case err := <-input.EOF():
545+ return nil, true, terminalReadCause(err)
546+ }
522 }547 }
523 }548 }
524 549
@@ -671,17 +696,21 @@ func (s *Supervisor) transition(event supervisorEvent) {
671 // terminalInputLifetime owns the single read of the controlling terminal. It is696 // terminalInputLifetime owns the single read of the controlling terminal. It is
672 // started once and never replaced: a bare io.Reader cannot be interrupted, so697 // started once and never replaced: a bare io.Reader cannot be interrupted, so
673 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the698 // the goroutine may outlive Run when stdin never reaches EOF, exactly like the
674-// attach client's input pump. It never closes caller-owned input, and P5.1a699+// attach client's input pump. It never closes caller-owned input. When a picker
675-// discards read bytes because picker interaction is a later slice.700+// consumer is supplied (Plan 001 P5.2b) each read is handed to it from this one
701+// reader, so the picker never starts a second reader; without a consumer the
702+// bytes are discarded exactly as P5.1a does.
676 type terminalInputLifetime struct {703 type terminalInputLifetime struct {
677- eof chan error704+ eof chan error
678- once sync.Once705+ once sync.Once
706+ consumer pickerInputConsumer
679 }707 }
680 708
681 // startTerminalInputLifetime starts the single terminal read. A nil reader is709 // startTerminalInputLifetime starts the single terminal read. A nil reader is
682-// treated as an immediate orderly EOF.710+// treated as an immediate orderly EOF. A nil consumer discards read bytes; a
683-func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime {711+// non-nil consumer owns them for the picker presentation.
684- lifetime := &terminalInputLifetime{eof: make(chan error, 1)}712+func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime {
713+ lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer}
685 if supervisorNil(in) {714 if supervisorNil(in) {
686 lifetime.finish(io.EOF)715 lifetime.finish(io.EOF)
687 return lifetime716 return lifetime
@@ -700,6 +729,11 @@ func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime {
700 // mistaken for EOF.729 // mistaken for EOF.
701 continue730 continue
702 }731 }
732+ if lifetime.consumer != nil {
733+ // The consumer may retain a partial escape or UTF-8 prefix, so it
734+ // gets its own copy rather than the reused read buffer.
735+ lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...))
736+ }
703 }737 }
704 }()738 }()
705 return lifetime739 return lifetime
M internal/usecase/client/supervisor_test.go +73 -12
@@ -239,26 +239,32 @@ func (c *supervisorTestConnector) awaitStart(t *testing.T) int {
239239 }
240240 }
241241
242-// supervisorTestHub is one broker publication source. subscribe hands out the
243-// current change channel, and publish retires and replaces it, so a stale
244-// subscription can never observe a newer publication.
242+// supervisorTestHub is one broker publication source. It conforms to
243+// ports.BrokerSubscription: each subscriber owns its own capacity-one channel,
244+// a publication offers a non-blocking wake to every live subscriber and never
245+// replaces or closes a channel, and a subscriber re-reads the newest snapshot
246+// on wake. A slow or parked subscriber therefore can never block the
247+// publisher or busy-spin.
245248 type supervisorTestHub struct {
246249 mu sync.Mutex
247250 snapshot ports.BrokerSnapshot
248- changed chan struct{}
251+ subs map[chan struct{}]struct{}
249252 }
250253
251254 func newSupervisorTestHub() *supervisorTestHub {
252- return &supervisorTestHub{changed: make(chan struct{})}
255+ return &supervisorTestHub{subs: make(map[chan struct{}]struct{})}
253256 }
254257
255258 func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) {
256259 h.mu.Lock()
260+ defer h.mu.Unlock()
257261 h.snapshot = snapshot
258- changed := h.changed
259- h.changed = make(chan struct{})
260- h.mu.Unlock()
261- close(changed)
262+ for changed := range h.subs {
263+ select {
264+ case changed <- struct{}{}:
265+ default:
266+ }
267+ }
262268 }
263269
264270 func (h *supervisorTestHub) current() ports.BrokerSnapshot {
@@ -268,14 +274,22 @@ func (h *supervisorTestHub) current() ports.BrokerSnapshot {
268274 }
269275
270276 func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription {
277+ changed := make(chan struct{}, 1)
271278 h.mu.Lock()
272- changed := h.changed
279+ h.subs[changed] = struct{}{}
273280 h.mu.Unlock()
274- return supervisorTestSubscription{changed: changed, closeFn: closeFn}
281+ return supervisorTestSubscription{changed: changed, closeFn: func() {
282+ h.mu.Lock()
283+ delete(h.subs, changed)
284+ h.mu.Unlock()
285+ if closeFn != nil {
286+ closeFn()
287+ }
288+ }}
275289 }
276290
277291 type supervisorTestSubscription struct {
278- changed <-chan struct{}
292+ changed chan struct{}
279293 closeFn func()
280294 }
281295
@@ -409,6 +423,12 @@ func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports.
409423 s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision})
410424 }
411425
426+// publishSnapshot publishes one complete broker snapshot, as a real service
427+// does, so a test can deliver a second-and-later catalogue publication.
428+func (s *supervisorTestService) publishSnapshot(snapshot ports.BrokerSnapshot) {
429+ s.hub.publish(snapshot)
430+}
431+
412432 func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed }
413433
414434 func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor {
@@ -1282,3 +1302,44 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) {
12821302 })
12831303 }
12841304 }
1305+
1306+// TestSupervisorTestHubModelsSubscriptionContract pins the fake against
1307+// ports.BrokerSubscription: each subscriber owns a distinct capacity-one
1308+// channel that is never replaced or closed on publish, and a publication is a
1309+// non-blocking offer so a parked subscriber can never busy-spin the publisher.
1310+func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) {
1311+ hub := newSupervisorTestHub()
1312+ first := hub.subscribe(nil)
1313+ second := hub.subscribe(nil)
1314+ require.NotEqual(t, first.Changed(), second.Changed(), "each subscriber owns its own channel")
1315+
1316+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 1})
1317+ for name, sub := range map[string]ports.BrokerSubscription{"first": first, "second": second} {
1318+ select {
1319+ case <-sub.Changed():
1320+ default:
1321+ t.Fatalf("subscriber %s must receive the publication", name)
1322+ }
1323+ }
1324+
1325+ // A second publication while a wake is still buffered is a non-blocking
1326+ // drop, and the channel identity is never replaced.
1327+ channel := first.Changed()
1328+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 2})
1329+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 3})
1330+ require.Equal(t, channel, first.Changed())
1331+ select {
1332+ case <-first.Changed():
1333+ default:
1334+ t.Fatal("a publication after the drained wake must offer a new wake")
1335+ }
1336+
1337+ first.Close()
1338+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 4})
1339+ select {
1340+ case <-first.Changed():
1341+ t.Fatal("a closed subscription must not receive further wakes")
1342+ default:
1343+ }
1344+ require.Equal(t, ports.BrokerRevision(4), hub.current().Revision)
1345+}
@@ -239,26 +239,32 @@ func (c *supervisorTestConnector) awaitStart(t *testing.T) int {
239 }239 }
240 }240 }
241 241
242-// supervisorTestHub is one broker publication source. subscribe hands out the242+// supervisorTestHub is one broker publication source. It conforms to
243-// current change channel, and publish retires and replaces it, so a stale243+// ports.BrokerSubscription: each subscriber owns its own capacity-one channel,
244-// subscription can never observe a newer publication.244+// a publication offers a non-blocking wake to every live subscriber and never
245+// replaces or closes a channel, and a subscriber re-reads the newest snapshot
246+// on wake. A slow or parked subscriber therefore can never block the
247+// publisher or busy-spin.
245 type supervisorTestHub struct {248 type supervisorTestHub struct {
246 mu sync.Mutex249 mu sync.Mutex
247 snapshot ports.BrokerSnapshot250 snapshot ports.BrokerSnapshot
248- changed chan struct{}251+ subs map[chan struct{}]struct{}
249 }252 }
250 253
251 func newSupervisorTestHub() *supervisorTestHub {254 func newSupervisorTestHub() *supervisorTestHub {
252- return &supervisorTestHub{changed: make(chan struct{})}255+ return &supervisorTestHub{subs: make(map[chan struct{}]struct{})}
253 }256 }
254 257
255 func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) {258 func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) {
256 h.mu.Lock()259 h.mu.Lock()
260+ defer h.mu.Unlock()
257 h.snapshot = snapshot261 h.snapshot = snapshot
258- changed := h.changed262+ for changed := range h.subs {
259- h.changed = make(chan struct{})263+ select {
260- h.mu.Unlock()264+ case changed <- struct{}{}:
261- close(changed)265+ default:
266+ }
267+ }
262 }268 }
263 269
264 func (h *supervisorTestHub) current() ports.BrokerSnapshot {270 func (h *supervisorTestHub) current() ports.BrokerSnapshot {
@@ -268,14 +274,22 @@ func (h *supervisorTestHub) current() ports.BrokerSnapshot {
268 }274 }
269 275
270 func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription {276 func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription {
277+ changed := make(chan struct{}, 1)
271 h.mu.Lock()278 h.mu.Lock()
272- changed := h.changed279+ h.subs[changed] = struct{}{}
273 h.mu.Unlock()280 h.mu.Unlock()
274- return supervisorTestSubscription{changed: changed, closeFn: closeFn}281+ return supervisorTestSubscription{changed: changed, closeFn: func() {
282+ h.mu.Lock()
283+ delete(h.subs, changed)
284+ h.mu.Unlock()
285+ if closeFn != nil {
286+ closeFn()
287+ }
288+ }}
275 }289 }
276 290
277 type supervisorTestSubscription struct {291 type supervisorTestSubscription struct {
278- changed <-chan struct{}292+ changed chan struct{}
279 closeFn func()293 closeFn func()
280 }294 }
281 295
@@ -409,6 +423,12 @@ func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports.
409 s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision})423 s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision})
410 }424 }
411 425
426+// publishSnapshot publishes one complete broker snapshot, as a real service
427+// does, so a test can deliver a second-and-later catalogue publication.
428+func (s *supervisorTestService) publishSnapshot(snapshot ports.BrokerSnapshot) {
429+ s.hub.publish(snapshot)
430+}
431+
412 func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed }432 func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed }
413 433
414 func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor {434 func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor {
@@ -1282,3 +1302,44 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) {
1282 })1302 })
1283 }1303 }
1284 }1304 }
1305+
1306+// TestSupervisorTestHubModelsSubscriptionContract pins the fake against
1307+// ports.BrokerSubscription: each subscriber owns a distinct capacity-one
1308+// channel that is never replaced or closed on publish, and a publication is a
1309+// non-blocking offer so a parked subscriber can never busy-spin the publisher.
1310+func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) {
1311+ hub := newSupervisorTestHub()
1312+ first := hub.subscribe(nil)
1313+ second := hub.subscribe(nil)
1314+ require.NotEqual(t, first.Changed(), second.Changed(), "each subscriber owns its own channel")
1315+
1316+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 1})
1317+ for name, sub := range map[string]ports.BrokerSubscription{"first": first, "second": second} {
1318+ select {
1319+ case <-sub.Changed():
1320+ default:
1321+ t.Fatalf("subscriber %s must receive the publication", name)
1322+ }
1323+ }
1324+
1325+ // A second publication while a wake is still buffered is a non-blocking
1326+ // drop, and the channel identity is never replaced.
1327+ channel := first.Changed()
1328+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 2})
1329+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 3})
1330+ require.Equal(t, channel, first.Changed())
1331+ select {
1332+ case <-first.Changed():
1333+ default:
1334+ t.Fatal("a publication after the drained wake must offer a new wake")
1335+ }
1336+
1337+ first.Close()
1338+ hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 4})
1339+ select {
1340+ case <-first.Changed():
1341+ t.Fatal("a closed subscription must not receive further wakes")
1342+ default:
1343+ }
1344+ require.Equal(t, ports.BrokerRevision(4), hub.current().Revision)
1345+}

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